24 Commits
Author SHA1 Message Date
butubb cc72591773 Merge branch 'dev'——拟人滑动(每台设备自己的手感)+ 任务级公共巡检 2026-09-16 13:14:02 +08:00
butubb 9a44eeba95 Merge branch 'feat/task-patrol'——任务级公共巡检(含与拟人滑动的合并:import、能力表两处冲突已解) 2026-09-16 13:10:05 +08:00
butubb 77eb53b4b2 Merge branch 'feat/human-swipe'——滑动拟人化 + 每台设备自己的手感 2026-09-16 13:09:31 +08:00
butubb c29516cff5 feat(任务): 任务级「公共巡检」——独立于步骤画布的守护条件(含 webhook 通知)
需求:任务编辑器里能单独配"这个任务每隔 N 秒检查一次"——熄屏就点亮、某个元素
出现就通知、掉出 App 就停本设备;通知标题正文要能自己写。

配置与执行分离(这是本次的关键设计):
- **配置是任务级的**(`params.watchers`),在任务编辑器单独一块,不进步骤画布;
- **执行是穿插的**:worker 每执行完一步、以及长等待的每个分片,看一眼哪个巡检
  到点了。不起线程 → 不需要并发模型,也不会和主流程抢屏幕(两边同时点屏幕会
  互相打断)。代价是精度受步长影响(某步卡 30s,巡检最多晚 30s),已在文档写明。

- core/patrol.py(新):检查项/动作注册表(CHECKS/ACTIONS)+ evaluate/act。
  检查:屏幕熄灭/亮着、元素存在/不存在、前台是/不是某 App;
  动作:只通知、点亮、息屏、停止本设备。屏幕走 `dumpsys power`(0.3s,
  不用 d.info——那玩意在部分设备要 14s),前台走 d.app_current()(0.7s)。
- tasks/generic/task.py:`_maybe_patrol` / `_run_patrol`(冷却、命中记一条
  步骤明细、发通知);**文案在动作之前渲染**——点亮后 {screen} 就成了"亮屏",
  用户要看的是"发现熄屏,已点亮"。
- 任务编辑器新增「公共巡检」块(static/admin/tasks.js)+ 样式;保存进 params.watchers。
- 通知:新增事件 `task.patrol.hit`(巡检命中)与 `task.notify.custom`(步骤发通知);
  给了 title 就用它当标题(不再拼前缀),level 字段可点名级别。

顺带(巡检需要的原语,也可单独用):
- if_el 条件判断支持 `selector_type=screen`(亮/熄)与 `foreground`(前台包名);
  非元素条件不参与「选择器健康」统计(否则会攒出假的"选择器失效"告警)。
- 新增两个步骤:`notify`(发自定义通知)、`stop_self`(停本设备,记"被停止"
  而不是失败,不触发重试)。步骤类型 18 → 20,相关文档计数一并更新。

修 bug:`wait` 步骤在巡检耗时超过剩余时间后 `sleep(负数)` 抛
"sleep length must be non-negative"(真机联调抓到,已 clamp 到 0)。

自测:假设备单测 12 组(命中/冷却/间隔/元素/前台/停止/静默/异常不炸);
真机联调:熄屏→点亮(False→True)+ 通知文案正确、掉出抖音按间隔命中 5 次、
长等待里穿插生效且步骤回到 ok、清理后用户通知配置原样恢复。
文档:TASK_DEV §4.5(含两个可抄的例子)与步骤表/条件类型、NOTIFY §3、README。
2026-09-16 13:02:40 +08:00
butubb cf075ba4e1 feat(任务): 滑动拟人化 + 每台设备有自己的手感(core/humanize.py)
用户反馈"滑动太像机器人""批量执行时每台设备都一样"。

core/humanize.py(新)分两个层次:
- **每次不同**:起止点/幅度/时长抖动、弧线方向随机——最容易被识别的不是"慢",
  是"每次都一模一样";
- **每台设备不同**:由 crc32(serial) 派生稳定的"性格"(手速 0.82~1.32、
  幅度 0.86~1.16、弧度、常用横坐标 ±9% 屏宽、停顿 0.80~1.35)。同设备多次运行
  风格一致,不同设备明显不同——13 台批量跑看着像 13 个人各刷各的。
  用独立 Random 实例播种,不碰全局 random(多线程 worker 会打乱取值顺序)。

轨迹用二次贝塞尔走 d.swipe_points(曲线),异常时自动退回直线 d.swipe。
**点数固定 4 个**:swipe_points 在慢设备上每多一个点约多 1 秒(实测 2 点
1.4s / 6 点 6.0s / 10 点 10.6s),设备自己会插值几十步,4 点已足够弯。

顺带修一个真机上的老毛病:滑动原本每次都要读 d.info,而它在部分设备上要
**14 秒**。改用 humanize.screen_size()(走 window_size,同设备 0.6s,缓存
120s)——所以哪怕多了弧线,真机单次滑动反而从 ~15s 降到 ~5s。

- tasks/generic/task.py:swipe / swipe_until 走拟人(新增 distance_ratio、
  jitter、humanize 参数,默认开);swipe_until 每轮停顿也抖动;wait 步骤新增
  可选 vary_pace(默认关,按设备节奏缩放 0.8~1.35 倍);
- static/admin/editor.js:滑动类步骤参数面板加"幅度/抖动/拟人轨迹"+ 说明;
- 文档:TASK_DEV.md §8.4(含"点数别调大""别用 d.info"两个坑)、步骤表、
  README 能力表与结构。

自测:假设备单测(曲线/抖动/设备间差异/退化路径/不越界/尺寸缓存)+ 真机联调
(4 次滑动全部 ok,标注"弧线"、坐标每次不同、对照的 humanize=false 仍是直线)。
2026-09-16 12:39:47 +08:00
butubb 92d71c0e27 Merge branch 'dev'——日志筛选/下载 + 任务步骤明细 + 通知降噪 2026-09-16 10:32:57 +08:00
butubb 243df3071b Merge branch 'fix/notify-noise'——通知降噪:多设备批次不逐台报成功、批次汇总瘦身、设备只显示名字 2026-09-16 10:32:51 +08:00
butubb 16e3e162b2 fix(通知): 多设备批次不再逐台报成功;批次汇总瘦身并列出失败设备;设备只显示名字
用户反馈:一条任务覆盖 13 台设备,每台成功都推一条,群里被刷屏;批次汇总里
「任务:抖音养号」与标题重复、「样本:抖音养号」毫无信息量、「stopped」还是英文。

- task_manager:
  · **多设备批次不发 `task.device.success`**(批次汇总里已有成功台数;单台任务照发);
    失败仍逐台发——那是少数,且要知道是哪台;
  · `_BatchTracker` 收集失败设备(`名字(型号):原因`,最多 5 条)并加进批次汇总;
  · 设备级事件带 `model`:型号取自**设备池快照**(批次开始时一次查好带下去),
    不是 worker 每次连接现采的(那次 `d.info()` 可能超时 → 空),也不查库;
- notifier:
  · 聚合窗口口径修正:事件声明 `agg_window=0`(批次结束/服务启停/备份恢复等低频
    高危事件)**不再被 hook 的窗口拖住**——原先事件级设置完全失效;
  · 「样本」行只在真的合并了多条(>1)时出现,且按**设备**维度写(合并多台时写
    任务名每条都一样);与标题重复的字段行(「任务:x」)不再重复渲染;
  · 设备名与地址同时存在时**只显示名字**(IP 是给日志看的);
  · 补 `stopped`/`failed_devices` 中文标签(原先直接显示英文 key);
- notify_events:批次事件补 `skipped`/`failed_devices` 字段,设备事件补 `model`。

实测(dev 真机 + 假接收端):
  批次 2 台 → 只收到 batch.started/finished,无 device.success,汇总 总数2/成功1/跳过1;
  单台任务 → 收到 device.success,显示「设备:cs1 / 型号:M2010J19SC」不含 IP。
文档:doc/NOTIFY.md §3(多设备批次怎么发 + 批次消息样例)、§6(窗口口径与注意事项)。
2026-09-16 09:47:25 +08:00
butubb 49544b1985 Merge branch 'feat/task-step-log'——任务步骤明细表 + 日志页「步骤明细」面板 2026-09-16 09:10:24 +08:00
butubb 8879da72b3 Merge branch 'feat/log-query'——日志页:关键字/级别/时间过滤 + 下载(含滚动历史) 2026-09-16 09:10:18 +08:00
butubb 621d48c800 feat(日志): 任务步骤明细表 + 「日志 → 步骤明细」面板(按设备/任务/时间过滤、按运行归组、导出 CSV)
文本日志只能 grep,"这台设备这次运行为什么失败"翻起来很费劲。新增一张
**结构化**的步骤明细表,把"哪一步、什么类型、哪个选择器、结果、耗时"落库。

- core/models.py:新增 task_step_log(run_id/job/设备/step_path/selector/
  result/detail/duration_ms),索引 run_id、created_at、(serial,created_at)、
  (job_id,created_at);
- core/step_log.py(新):**专用写线程 + 有界队列**批量落库——步骤执行是热路径,
  任务线程只 put_nowait(实测 9000 次入队 31ms),队列满丢弃并计数,绝不阻塞;
  另有保留期清理(默认 14 天,每日 04:13 + 每次启动);
- tasks/generic/task.py:_exec_one 记一条(异常=error / handler 返回 False=miss /
  未知类型=unknown / 概率未触发=skip);_exec_steps 维护路径栈得到 "2.1.3"
  这样的嵌套位置;**单次运行封顶 2000 条**——forever 循环任务否则会写爆表;
- 运行上下文 ctx(run_id/job_id/job_name/device_name)由 TaskManager 生成,
  经 create_worker(serial, params, ctx=None) 传入 worker(扩展点向后兼容);
- 接口:/api/step_logs(过滤+分页)、/runs(按运行归组)、/filters(下拉选项)、
  /download(CSV,带 BOM);
- 前端:日志页拆成「文件日志 / 步骤明细」子分栏 + static/admin/steplog.js。

红线:新表自动进备份覆盖清单(SUMMARY_TABLES 由元数据派生),已补
TABLE_LABELS 中文标签,导出实测 14 张表、coverage_missing 为空。

文档:DATA_MODEL §2.8/§1、API §10.2、ARCHITECTURE §1.1/§2.2/§3.1、
DEVELOPMENT §5.2 与红线表、DEPLOY §5.2、TASK_DEV §8.3、README。
2026-09-16 08:58:43 +08:00
butubb 2c32c397c8 feat(日志): 日志页支持关键字/级别/时间过滤 + 下载(含滚动历史)
「日志」页原来只能 tail 固定行数、且文件下拉写死了 4 项(新增的 notify.log
根本选不到)。本次:

- core/logger.py 新增读取接口:list_log_files()(模块文件 + .1/.2 滚动历史,
  白名单)、query_log()(关键字/最低级别/时间过滤,返回结构化行)、
  read_log_text()(导出)。
  · 时间/级别过滤对**续行**用继承值(traceback 缩进行本身没有前缀),
    按 ERROR 筛不会把堆栈拆散;
  · 过滤按"最近 N 条命中"返回,保持文件原顺序;
- GET /api/logs 换新协议(rows/files/matched/scanned/truncated),
  新增 GET /api/logs/download(附件下载,支持同样过滤;无一条件时整文件);
- 顺带修 **目录穿越**:原实现 os.path.join(_LOG_DIR, file) 可用 ../../ 逃逸,
  现在 file 必须命中白名单;
- 顺带修 web/admin_api.py 里 _log 未定义(用户增删改的日志行会 NameError);
- 前端:文件下拉改为按接口渲染、加关键字(命中高亮)/级别/时间范围/下载/重置,
  自动刷新时不再把正在上翻的用户拽回底部。

文档:doc/API.md §10.1(参数与返回)、README 日志节。
2026-09-16 08:44:19 +08:00
butubb 6fa90a505c Merge branch 'dev' 2026-09-15 14:32:11 +08:00
butubb 3b6ec8c98f feat(通知): 加钉钉 / 飞书两种格式(含各自的加签算法)
## 两家签名算法不一样,照抄必错
| | 钉钉 | 飞书 |
|---|---|---|
| HMAC key | `secret` | `"{timestamp}\n{secret}"` |
| 被签内容 | `"{timestamp}\n{secret}"` | 空 |
| timestamp | 毫秒 | 秒 |
| 拼在哪 | URL query | JSON body |
| 结果 | Base64 再 urlencode | Base64 |
| 出错码 | `errcode 310000` | `code 19021` |

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

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

## 验证(都跑过)
- **签名对拍官方示例**:钉钉逐字节一致(含 urlencode,`%2B` 级别)、飞书的 key/message 口径一致;
  时间戳钉住后比对,不靠"自己跟自己一致"
- 成功码按格式:wecom/dingtalk/feishu 的 0 与 bark 的 200 分别判成功,各自的错误码判失败
- 端到端 15 项:钉钉/飞书在假接收端上真发(URL 带签名、body 带 timestamp/sign、卡片是 markdown 元素、
  毫秒 vs 秒的时间戳),签名错时正确判失败(310000 / 19021),token 在回显里被打码
2026-09-15 14:29:11 +08:00
butubb 515cb5d580 docs: 根 README 结构表/前端 JS 加载清单补上 notify(含 notify_api/notifier/taskgen/notify.js,并修正文件数) 2026-09-15 14:05:44 +08:00
butubb 24ea289c15 feat(通知): 加 Bark 格式 + 格式切换时界面提示全部跟着变
## 用户报的第二件事:格式改了,提示没跟着改
弹窗里的 URL 示例/说明、密钥标签、限流说明都写死成企业微信了——选「通用 JSON」时
占位还是 `qyapi.weixin.qq.com`,限流还写着「企微硬限 20」。现在这些文案挂在**适配器**上
(`url_hint/url_help/secret_label/secret_help/limit_help`),由接口随格式下发,切格式即时更新;
限流值在用户没手动改过时也跟着格式的推荐值走(企微 20 / 通用 JSON 60 / Bark 60)。

## 新格式:Bark(iOS 推送)
- `POST {url}`,body `{title, body, markdown, group, level[, device_key]}`
  —— `markdown` 传富文本、`body` 传纯文本兜底(老版本 App 不认 markdown 字段时也能看清)
- URL 两种填法都支持:直接粘 Bark 复制的那串(`https://api.day.app/<key>`,key 在路径里),
  或填 `https://api.day.app/push` + 把 key 填到「设备 Key」(作为 device_key 发送)
- **成功判定按格式**:Bark 是 `code==200`(企业微信是 `errcode==0`)→ 新增
  `BaseAdapter.ok_codes`,`_post_once` 用它判定。少了这一步 Bark 的每次成功都会被误判成失败
- 正文按 2048 字节截断(走 APNs,体量有限)

## 顺带
- 通用 JSON 的 `secret` 现在会作为 `X-Webhook-Secret` 请求头发出(原先填了没用)
- `_formats()` 改为序列化适配器元信息,新增格式只改一处

## 验证
- 格式切换 14 项断言全绿:三种格式的 URL 示例/密钥标签/密钥说明/限流说明/模板区/限流默认值
  全部跟着切换;下拉里 Bark 出现在已实现区
- Bark 端到端 7 项:`code=200` 判成功、`code=400` 判失败(含重试)、请求体带
  device_key/title/body/markdown/group、URL 里的 key 在接口回显里被打码
- 文档:NOTIFY.md §5 的格式表补 Bark 列(URL 怎么填 / 成功码 / 约束),并说明
  "提示文案挂在适配器上,别写死在页面里"
2026-09-15 14:05:33 +08:00
butubb 1e223e847b chore(通知): 补上漏提交的 web_server.py 接线(init_app 时序 + 启动/停止通知)
上一轮的 git add 路径列表漏了 web_server.py——功能本身在跑(文件在磁盘上),
但提交里缺这一段,别人拉下来会因为没调 init_app 而收不到任何通知。
2026-09-15 13:59:13 +08:00
butubb 9a6c0db65c fix(通知): 新建/编辑弹窗缺「保存/关闭」整条操作栏(点进去出不来、也存不了)
用户报:新建 webhook 弹窗**没有关闭按钮**——点进去不想建就出不来了(只能刷新页面)。
顺着查发现更严重的一处:**底部操作栏整个漏了**——「创建/保存」「预览」「取消」全都没有,
只是靠"以为有保存按钮所以先点开看看"才没被立刻发现:其实填完也提交不了。

修(static/admin/notify.js + monitor.html):
- 弹窗顶部加「✕ 关闭」,底部加「创建/保存 · 预览请求体 · 取消」操作栏
- 四条关闭路径都可用:✕ / 取消 / **Esc** / **点空白处**(overlay 的 onclick)

同批复核出的其它问题:
- 通配订阅区被我上一版写成永远隐藏(`display:none` 且没人改回来)→ 恢复可见
  (任何格式都适用,标签补成「通配订阅(可选,任何格式都适用)」)
- `_notifyFormatChanged` 里残留 `(f === 'json' || true)` 的无意义判断 → 清掉
- 工具条上一个 input 写了**两个 style 属性**(后者会被浏览器忽略)→ 合并成一个

复核方式(这次全部**走页面按钮**,不再只测接口):
- UI 全流程 13 项:四条关闭路径 + 底部按钮存在 + 页面创建(列出/打码 URL/事件数)
  + 编辑回填与改名 + 发送测试(假接收端真收到)+ 页面删除,无 JS 报错
- 通配订阅 6 项:区可见 / 非法通配被拒 / 芯片渲染 / 保存后列出 / 编辑回填 / 清理
- 接口边界 10 项:提交打码 URL 不覆盖真实值、只传 enabled 的局部更新、非法模板被拒、
  预览字节数、limit 非法值兜底、404 分支、未登录被挡
教训:交付物是界面时,自测必须走界面点一遍——上一轮我用接口建的 webhook,
把「按钮不存在」整类问题漏掉了。
2026-09-15 13:59:00 +08:00
butubb 97cec6e211 feat(通知): 系统级 Webhook 通知子系统(事件目录 + 可插拔适配器 + 防刷屏)
平台此前出了问题只能靠人盯页面。现在各组件统一走 `notifier.notify(事件, **字段)`,
推到企业微信 / 自建服务;**所有可通知点都登记进事件目录,默认全关,用户按 webhook 勾选**。

## 架构(`core/notifier.py` + `core/notify_events.py`)
业务线程调 `notify()` → 只做内存操作(读配置快照/匹配订阅/入队)→ 返回;
后台 1 个 dispatcher(聚合 + 每 hook 限流 + 折叠摘要)+ 3 个 sender(真实 HTTP、退避重试)
负责真正发出去。硬约束:**notify 零 DB、零 HTTP、零阻塞、异常不冒泡**——所以任务线程里
可以直接调(不用 app_context、不用 try/except),但**必须放在所有 `with self._lock` 之外**。

- **事件目录 36 条**(任务批次/单设备/设备/Worker/业务/安装/系统/AI),支持 `task.*` 通配订阅;
  语义分工避免重复告警:`worker.*` 是单次尝试级,`task.device.success/failed` 是唯一权威结论。
- **适配器可插拔**:`wecom`(markdown,按 4096 **字节**截断、超限不截半个汉字)+
  `json`(模板占位符,替换值按 JSON 转义,保存前干跑校验);钉钉/飞书留了插槽(前端置灰)。
- **防打爆四层**:聚合窗口(默认 30s,同批次合并成一条并带样本)→ 令牌桶限流(默认 18/分,
  对齐企微硬限 20)→ 被限流的**折叠成摘要不丢弃** → 有界队列背压。取舍:失败通知最多延迟
  一个窗口,换来群不被刷屏。

## 安全与存储
- 配置只落 `app_meta.notify_webhooks` 一个键(**不建表** → 不涉及备份覆盖红线)。
- URL 本身就是凭据(企微 `?key=`)→ 接口回显/发送记录/日志一律 `mask_url()/scrub()`;
  编辑时留空即不修改;secret 永不回显。DATA_MODEL 的明文凭据告警补上了这一条。
- 发送记录:内存环形缓冲 200 条(重启清空)+ 独立 `logs/notify.log`。

## 接入点(每个都放在锁外、不改 return 顺序)
task_manager(批次开始/结束用新增的 `_BatchTracker` 统一在 finally 计数、单设备成功/失败/
离线/重试/停止/抢占/归还/cron 停止)、device_worker 心跳看门狗、generic 任务选择器连续失效、
apk 安装开始/完成、设备上下线(**状态沿检测**,只报新变化)、备份导出/恢复、经验巡检、
用户登录、服务启停。

## 前端
系统 Tab 新增「通知 / Webhook」子分栏:多条 webhook 列表(URL 打码)+ 编辑弹窗(格式/URL/
密钥/事件勾选树带 ★建议/聚合/限流/自定义模板/预览)+ 发送测试 + 发送记录。

## 自测
- 进程内逻辑 10 组断言全绿:聚合合并、限流+折叠、无配置/全局关静默丢弃、未知事件、
  内部异常不外泄、JSON 转义(标题含引号换行仍合法)、URL/异常消息脱敏、配置校验。
- 端到端(假 webhook 接收端)17 项断言全绿:真实事件投递(user.login / task.batch.no_device)、
  企微请求体形状、**HTTP 200 + errcode 93000 判为失败**、500 重试 3 次、记录里 URL 打码。
- 韧性:webhook 指向黑洞地址时登录耗时 100~114ms(基线 107~133ms,**异步隔离生效**);
  配置写成坏 JSON 服务照常启动、通知静默不发、日志有 error(服务端实测后已复原)。
- 页面:系统 → 通知 面板/弹窗/36 个事件复选框/预览全部正常,无 JS 报错。
- 自测数据已清理(webhook、自建任务、写坏又复原的配置键)。

文档:新增 doc/NOTIFY.md(事件表/配置/格式约束/防刷屏/加事件三步骤/排障)并登记进 doc/README;
API.md §2.11;DATA_MODEL 的 app_meta 键表与明文凭据告警;ARCHITECTURE 线程表/分层/扩展点;
根 README 功能索引与日志表。
2026-09-15 13:55:14 +08:00
butubb 5e0a7d5556 fix(屏幕): 一键息屏不再唤醒已眠设备 + 「保持亮屏」对不充电的设备生效
用户反馈两条:
1. 监控页「一键息屏」像是把设备**唤醒**了(按钮行为"反");
2. 任务里的「保持亮屏」没用。

根因:
1. `POST /api/device/screen_all` 的 off 分支发 `input keyevent 26`(KEYCODE_POWER)——
   那是电源键**开关**:对亮着的设备是熄屏,对**已经息屏的设备反而是唤醒**。
2. `keep_screen` 只发 `svc power stayon true`,它管的是「**充电时**屏幕常亮」
   (stay_on_while_plugged_in)。设备走 WiFi 跑任务、没插充电器 → 完全不生效。

修法:
- 息屏改用 `KEYCODE_SLEEP(223)`(单向:只熄不亮)。
- 保持亮屏改成把系统**息屏超时**顶到最大(`settings put system screen_off_timeout
  2147483647`)+ 顺带 `svc power stayon true` + 立刻 `KEYCODE_WAKEUP` 唤醒一次;
  `mode=off` 时写回原值(进入时读一次记在内存;进程重启丢了记录就写回 10 分钟兜底,
  `settings get` 返回 "null" 的机型也走兜底)。
- 文案/文档同步:编辑器里的步骤说明、TASK_DEV 步骤表、API.md 的接口行为。

自测(按用户要求不动机器):py_compile / node --check 通过;用假设备对象断言命令序列——
保持亮屏发 `settings get` → `stayon true` → `put …2147483647` → `keyevent 224` 并记住原值;
恢复写回原值 60000 且清空备份;原值为 null 时兜底 600000。
2026-09-15 13:24:28 +08:00
butubb afdea347ee Merge branch 'dev' 2026-09-14 10:34:13 +08:00
butubb 6f14529db0 docs(流程): 明确「修复也走分支流程,含线上紧急修复」
2026-09-14 当天两次 APK 安装修复(615f9cf / 7b6c838)因为"生产卡着"直接提交在 dev 上、
紧接着合 main 部署,跳过了 ① 切分支 与 ③ 交负责人确认(把"合 dev 确认"与"部署确认"
揉成了一次)。负责人指出后补齐这条约束。

补充内容(§1.2 git 铁律):
- 紧急修复照样从 dev 切 fix/xxx,不允许直接 commit/push 到 dev
- 第 ② 步自测要跑通本机服务,不能只有脚本级验证
- 第 ③ 步确认要点名两件事:「是否合 dev」与「是否合 main + 部署生产」
- 线上正卡住时先恢复(运维动作,经确认即可),再按流程走修复
2026-09-14 10:33:43 +08:00
butubb b11d36a52e Merge branch 'dev' 2026-09-14 10:29:26 +08:00
butubb 7b6c8386af fix(APK 安装): 推送卡死会永久挂住整批安装(新增停滞看门狗 + 收尾兜底)
用户报:220 上一个安装任务卡住不动。(现场:rednote 169.6MB 装 6 台,
5 台成功,192.168.20.203 卡在「推送中 67%(114.0/169.6 MB)」9 分钟没动静——
那台设备当时正跑着任务,adb 流量撞在一起把推送链路卡住了。)

两个叠加的缺陷:
1. 分块推送的 `proc.stdin.write()` **没有任何超时**:链路一卡就永久阻塞,
   安装线程挂死。
2. 批处理的 `as_completed(timeout=...)` 超时后异常直接冒泡出去(`with
   ThreadPoolExecutor` 退出时还会 wait 卡住的线程),`finished=True` 永远没被
   置位 —— 于是界面上永远是"安装中",且后续安装全被「已有安装任务正在进行」
   挡住(用户看到的"卡住")。

修法(core/apk_manager.py):
- 新增**停滞看门狗**:盯着"已写字节数"是否推进,`_PUSH_STALL_TIMEOUT`(90s) 没进展
  就 kill 掉 adb,阻塞中的写立刻以异常返回 → 退回 `adb push` 重传。
  (为什么不用非阻塞写/管道 select:`os.set_blocking` 是 Unix 专有,开发机是 Windows,
  看门狗两边都能用。)
- 批处理改成 try/except/finally:超时或异常时把还没结果的设备标失败,**无论如何**
  都置 `finished=True`;`pool.shutdown(wait=False)` 不再为卡住的线程陪等。

验证:
- 假 adb 模拟"永不读 stdin"(链路卡死)→ 4 秒识别停滞 → kill → 回退 push 成功,
  全程 6.1s(旧代码在这里永久挂死)。
- 真机正常路径不受影响:5.9MB 推送 1.7s、设备侧字节数一致。
2026-09-14 10:28:22 +08:00
41 changed files with 4751 additions and 163 deletions
+27 -9
View File
@@ -46,13 +46,16 @@
| **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) | | **多设备并发任务** | 每设备一个 Worker 线程;同一设备同时只跑一个任务(可配置"抢占"打断其他任务) |
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 | | **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) | | **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
| **步骤编辑器** | 可视化拖拽编排 18 种步骤(含循环/条件/OCR),可打包成"自定义动作"复用 | | **步骤编辑器** | 可视化拖拽编排 20 种步骤(含循环/条件/OCR/通知),可打包成"自定义动作"复用 |
| **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) |
| **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) |
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 | | **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) | | **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 | | **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
| **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 +123,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 +140,10 @@ auto_control/
│ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道) │ ├── clipboard_helper.py # 剪贴板注入(ClipInject 通道)
│ ├── apk_manager.py # APK 上传/解析/批量安装 │ ├── apk_manager.py # APK 上传/解析/批量安装
│ ├── system_backup.py # 数据备份导出/导入(重启生效) │ ├── system_backup.py # 数据备份导出/导入(重启生效)
│ ├── humanize.py # 拟人化:弧线滑动/抖动 + 每台设备的"手感"(按 serial 播种)
│ ├── notifier.py # 通知分发(队列/聚合/限流/适配器)+ notify_events.py 事件目录
│ ├── step_log.py # 任务步骤明细(异步写线程 + 保留期清理)
│ ├── patrol.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)
@@ -144,7 +152,7 @@ auto_control/
├── tasks/ # 任务定义层 ├── tasks/ # 任务定义层
│ ├── base.py # BaseTask + _TASK_TYPES + register_task │ ├── base.py # BaseTask + _TASK_TYPES + register_task
│ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型) │ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型)
│ └── task.py # STEP_TYPES(18 种步骤)+ Worker + 执行器 │ └── task.py # STEP_TYPES(20 种步骤)+ Worker + 执行器
│ │
├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033) ├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033)
│ ├── mcp_server.py # 工具定义 + 平台登录 + 门控 │ ├── mcp_server.py # 工具定义 + 平台登录 + 门控
@@ -162,7 +170,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 +189,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 → steplog.js
``` ```
--- ---
@@ -242,8 +250,8 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
| Tab | 内容 | 可见性 | | Tab | 内容 | 可见性 |
|-----|------|--------| |-----|------|--------|
| **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") | | **监控** | 统计卡片 + 设备表(在线/型号/任务状态/进度/前台 App/最近错误,支持排序、搜索、分页、多选批量操作)+ 异常汇总 + **任务运行概况**(每张任务卡:执行任务 / 停用任务 + 覆盖设备彩色 chip);导航栏「📺 大屏」打开 `/wall` | 所有登录用户(设备操作按钮需"设备控制") |
| **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" | | **任务** | 子分栏:**任务计划**(CRUD / 启停 / 执行 / 下次运行时间 / 离线跳过 / 步骤编辑器 / **公共巡检**)、**自定义动作**(步骤打包复用) | 可看;写操作需"任务管理" |
| **日志** | 实时日志(按文件切换、可自动刷新) | 需"日志查看" | | **日志** | 子分栏:**文件日志**(文件切换含滚动历史、关键字/级别/时间过滤、命中高亮、下载筛选结果、自动刷新)、**步骤明细**(按设备/任务/结果/时间过滤、按运行归组的概览、导出 CSV) | 需"日志查看" |
| **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 | | **用户** | 用户 CRUD、改密、分配权限 | 仅管理员 |
| **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 | | **工具** | 8 个子分栏:剪贴板注入 / adb 远程终端 / Tailscale 管理 / 应用管理 / 应用版本管理 / 设备已装应用 / **设备池管理**(含自动发现)/ 设备分组 | 仅管理员 |
| **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 | | **AI 控制台** | 会话列表 + 对话区(Markdown 渲染、推理链折叠、token 统计)+ 实时画面(MJPEG)+ 目标设备选择;右上角:经验库 / 动作库 / 模型配置 | 仅管理员 |
@@ -270,7 +278,7 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
`generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。 `generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。
**18 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)): **20 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
| 类别 | 步骤 | | 类别 | 步骤 |
|------|------| |------|------|
@@ -347,13 +355,23 @@ 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
log = get_logger("task.generic") # → logs/task.log log = get_logger("task.generic") # → logs/task.log
``` ```
「日志」Tab 可在线查看。 「日志」Tab 可在线查看:文件下拉包含 `.1/.2…` 滚动历史(可回溯更早的日志),
支持关键字(命中高亮)/最低级别/时间范围过滤,点「下载」导出当前筛选结果
(无筛选就是整个文件)。读取逻辑在 `core/logger.py` 的 `list_log_files()/query_log()/
read_log_text()`,`file` 参数走白名单,不接受任意路径。
「日志 → 步骤明细」是**结构化**的另一半:每一次步骤执行落一行 `task_step_log`
(设备/任务/步骤路径/结果/耗时/选择器),因此能按设备、任务、结果、时间过滤,
也能按 `run_id` 归组看"这一次运行为什么失败"、导出 CSV。写入是异步的
(`core/step_log.py`,任务线程只入队,不阻塞执行),保留 14 天、单次运行最多
2000 条 —— 这两个上限决定这张表(以及备份包)能长多大。
--- ---
@@ -382,7 +400,7 @@ log = get_logger("task.generic") # → logs/task.log
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) | | [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) |
| [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) | | [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) |
| [doc/API.md](doc/API.md) | HTTP 接口全量说明 | | [doc/API.md](doc/API.md) | HTTP 接口全量说明 |
| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(18 种步骤、选择器、自定义动作、新增任务类型) | | [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(20 种步骤、公共巡检、选择器、自定义动作、新增任务类型) |
| [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 | | [doc/MCP.md](doc/MCP.md) · [doc/MCP_DESIGN.md](doc/MCP_DESIGN.md) | MCP 使用手册 / 设计文档 |
| [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) | | [doc/AI_CONSOLE.md](doc/AI_CONSOLE.md) · [doc/AI_TASK_GEN.md](doc/AI_TASK_GEN.md) | AI 控制台机制 / AI 建任务设计(规划) |
| [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) | | [doc/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) |
+71 -4
View File
@@ -22,12 +22,15 @@
import os import os
import time import time
import uuid import uuid
import threading import threading
import subprocess import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import (ThreadPoolExecutor, as_completed,
TimeoutError as FuturesTimeout)
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
@@ -35,6 +38,9 @@ _log = get_logger("core.apk")
# adb install 超时(秒)。大 APK 安装慢,给 5 分钟 # adb install 超时(秒)。大 APK 安装慢,给 5 分钟
_INSTALL_TIMEOUT = 300 _INSTALL_TIMEOUT = 300
# 分块推送"多久没有任何字节被接收"就算链路卡死(秒)。注意这是**停滞**判据,
# 不是总时长:正常推送会不断刷新它。
_PUSH_STALL_TIMEOUT = 90
# 并发安装数 # 并发安装数
_INSTALL_CONCURRENCY = 5 _INSTALL_CONCURRENCY = 5
@@ -315,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,
@@ -333,7 +341,10 @@ class ApkManager:
f"跳过={len(serials)-len(install_list)}") f"跳过={len(serials)-len(install_list)}")
if install_list: if install_list:
with ThreadPoolExecutor(max_workers=_INSTALL_CONCURRENCY) as pool: # 不用 with(它退出时会 wait 卡住的线程):超时后要能立刻收尾,
# 否则界面永远停在"安装中",后续安装全被"已有安装任务在进行"挡住
pool = ThreadPoolExecutor(max_workers=_INSTALL_CONCURRENCY)
try:
futures = {pool.submit(self._install_one, s, apk_path, futures = {pool.submit(self._install_one, s, apk_path,
package_name): s package_name): s
for s in install_list} for s in install_list}
@@ -343,8 +354,23 @@ class ApkManager:
fut.result() fut.result()
except Exception as e: except Exception as e:
self._set_item(s, "failed", f"安装异常: {e}") self._set_item(s, "failed", f"安装异常: {e}")
except FuturesTimeout:
_log.warning(f"安装任务 {apk_name}: 有设备超时未返回,先收尾")
for s in install_list:
it = ((self._install_task or {}).get("items") or {}).get(s) or {}
if it.get("status") in ("pending", "installing"):
self._set_item(s, "failed", "安装超时(设备无响应)")
except Exception as e:
_log.exception(f"安装任务 {apk_name} 异常")
for s in install_list:
it = ((self._install_task or {}).get("items") or {}).get(s) or {}
if it.get("status") in ("pending", "installing"):
self._set_item(s, "failed", f"安装异常: {e}")
finally:
pool.shutdown(wait=False)
# 标记完成 # 标记完成(**必须**落到这里:没置 finished 的安装任务会一直挂着,
# 让"已有安装任务正在进行"永久挡住后续安装 —— 2026-09-14 事故)
if self._install_task: if self._install_task:
self._install_task["finished"] = True self._install_task["finished"] = True
# 统计 # 统计
@@ -355,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。
@@ -498,6 +534,33 @@ class ApkManager:
_log.warning(f"[{serial}] exec-in 启动失败,退回 adb push: {e}") _log.warning(f"[{serial}] exec-in 启动失败,退回 adb push: {e}")
return self._push_fallback(serial, apk_path, remote) return self._push_fallback(serial, apk_path, remote)
# 看门狗:链路卡死时 `proc.stdin.write()` 会**永久阻塞**(设备掉线、
# WiFi 断了、任务在抢 adb…)→ 线程挂死 → 整批安装永不 finished → 界面
# 卡在"安装中"、后续安装全被"已有安装任务在进行"挡住(2026-09-14 事故)。
# 这里盯住"已写字节数"是否还在推进,停滞超时就 kill 掉 adb —— 阻塞中的
# write 会立刻以异常退出,走下面的回退逻辑。
# 为什么用看门狗而不是非阻塞写:`os.set_blocking`/管道 select 是 Unix 专有,
# 开发机是 Windows,看门狗两边都能用。
stop_watch = threading.Event()
def _stall_watchdog():
last_seen, since = -1, time.time()
while not stop_watch.wait(2.0):
if sent == total:
return
if sent != last_seen:
last_seen, since = sent, time.time()
elif time.time() - since > _PUSH_STALL_TIMEOUT:
_log.warning(f"[{serial}] 推送停滞 {int(time.time() - since)} 秒"
f"({sent}/{total} 字节),杀掉推送进程并回退")
try:
proc.kill()
except Exception:
pass
return
watchdog = threading.Thread(target=_stall_watchdog, daemon=True)
watchdog.start()
try: try:
with open(apk_path, "rb") as f: with open(apk_path, "rb") as f:
while True: while True:
@@ -523,7 +586,11 @@ class ApkManager:
proc.kill() proc.kill()
except Exception: except Exception:
pass pass
return False, f"推送中断: {e}" _log.warning(f"[{serial}] exec-in 推送中断({e}),已传 {sent}/{total} 字节,"
f"退回 adb push 重传")
return self._push_fallback(serial, apk_path, remote)
finally:
stop_watch.set()
try: try:
rc = proc.wait(timeout=_INSTALL_TIMEOUT) rc = proc.wait(timeout=_INSTALL_TIMEOUT)
+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),
+15 -2
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()
@@ -265,10 +275,13 @@ class BaseWorker(threading.Thread):
其他异常 — 可重试 其他异常 — 可重试
""" """
def __init__(self, serial, params=None, daemon=True): def __init__(self, serial, params=None, daemon=True, ctx=None):
super().__init__(daemon=daemon) super().__init__(daemon=daemon)
self.serial = serial self.serial = serial
self.params = params or {} self.params = params or {}
# 本次运行的上下文(run_id / job_id / job_name / device_name),由
# TaskManager 传入,供步骤明细等"结构化记录"标注来源。可空。
self.ctx = dict(ctx or {})
self._stop_flag = threading.Event() self._stop_flag = threading.Event()
self.d = None # u2.Device,run_task 里用 self.d = None # u2.Device,run_task 里用
# 通用进度字段(子类通过 set_progress 上报) # 通用进度字段(子类通过 set_progress 上报)
+186
View File
@@ -0,0 +1,186 @@
"""拟人化操作:滑动不机械,且**每台设备有自己的手感**。
分两个层次,别混在一起:
1. **每次都不一样**(抖动):起点/终点位置、滑动幅度、时长、弧线方向都随机——
最容易被识别的特征不是"慢",而是"同一帧重复播放"。
2. **每台设备稳定不同**(性格):由 `serial` 派生一份固定的偏好(手速、幅度、
弧度、停顿、常用触点的横向位置)。同一台设备多次运行风格一致,不同设备之间
明显不同——批量跑 13 台时看起来像 13 个人各刷各的,而不是 13 台同步机器人。
**性格稳定、抖动随机**,两者都由 serial 播种,不引入全局状态、不做任何 IO。
性格怎么来的:`crc32(serial)` 作种子 → 几个围绕 1.0 的比例因子(见 `profile()`)。
换设备换风格,改名字**不改**风格(按 serial 而不是设备名),换地址会换风格——
这符合直觉:地址代表"这一台"。
想手工看一眼某台设备的性格:`python -c "from core import humanize; print(humanize.profile('192.168.20.55:5555'))"`
"""
import random
import time
import zlib
from core.logger import get_logger
_log = get_logger("core.humanize")
_PROFILE_CACHE = {}
# serial -> (w, h, ts):屏幕尺寸缓存(见 screen_size)
_SIZE_CACHE = {}
_SIZE_TTL = 120
def profile(serial):
"""设备性格(按 serial 缓存,恒定)。字段都是围绕 1.0 的比例因子。"""
key = str(serial or "")
p = _PROFILE_CACHE.get(key)
if p is not None:
return p
# 用独立的 Random 实例而不是全局 random:worker 是多线程的,全局 random 的
# 取值顺序会被其它线程打乱 —— 那样"每台设备的性格"就串味了
r = random.Random(zlib.crc32(("human:" + key).encode("utf-8")))
p = {
"speed": r.uniform(0.82, 1.32), # <1 划得快(时长短),>1 划得慢
"distance": r.uniform(0.86, 1.16), # 有人习惯一划到底,有人小步快跑
"curve": r.uniform(0.4, 1.6), # 弧线弯度系数
"x_bias": r.uniform(-0.09, 0.09), # 常用触点的横向偏移(屏幕宽比例)
"pause": r.uniform(0.80, 1.35), # 停顿倍率(wait 步骤可选启用)
}
_PROFILE_CACHE[key] = p
return p
def screen_size(d, serial=""):
"""屏幕尺寸,带缓存。
为什么不用 `d.info`:它在部分设备上**极慢**(实测 14s,见 doc/backlog),而
尺寸在一次运行里根本不会变。`d.window_size()` 快得多(同设备 0.6s)。
缓存 120s 过期:够躲开慢调用,又能在屏幕旋转/换分辨率后自愈。
"""
key = str(serial or "")
now = time.time()
hit = _SIZE_CACHE.get(key)
if hit and now - hit[2] < _SIZE_TTL:
return hit[0], hit[1]
try:
w, h = d.window_size()
except Exception: # 老 agent 没有 window_size 就退回 info
info = d.info
w, h = info["displayWidth"], info["displayHeight"]
w, h = int(w), int(h)
if w > 0 and h > 0:
_SIZE_CACHE[key] = (w, h, now)
return w, h
def _bezier(p0, p1, p2, n):
"""二次贝塞尔采样——手指划出来是弧线,不是尺子画的直线。"""
pts = []
for i in range(n + 1):
t = i / n
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0]
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1]
pts.append((int(round(x)), int(round(y))))
# 同样的点会连续出现(曲线很平时)——去掉,免得下发冗余坐标
out = [pts[0]]
for p in pts[1:]:
if p != out[-1]:
out.append(p)
return out
def swipe(d, direction, serial="", duration_min=0.25, duration_max=0.50,
distance_ratio=0.6, jitter=0.15, humanize=True):
"""按人类习惯滑一下(上/下/左/右)。
参数:
direction 上滑 up / 下滑 down / 左滑 left / 右滑 right
serial 用来取本设备的性格(不传就是"普通人")
duration_min/max 手指接触屏幕的时长区间(秒),再乘本设备手速系数
distance_ratio 滑动幅度占屏幕高(竖滑)或宽(横滑)的比例,默认 0.6
jitter 抖动幅度 0~0.4:位置/幅度/时长各抖这么多;0 = 每次都一样
humanize False = 退回老的直线滑动(完全规则,用于对照/排障)
返回实际参数 dict(时长/起止点),方便日志与排障。异常向上抛,由调用方兜。
"""
w, h = screen_size(d, serial)
prof = profile(serial)
vertical = direction in ("up", "down")
j = max(0.0, min(float(jitter or 0), 0.4))
# ---- 时长:用户给的范围 × 本设备手速 × 本次抖动 ----
dur = random.uniform(float(duration_min), float(duration_max))
dur *= prof["speed"]
if j:
dur *= 1 + random.uniform(-j, j) * 0.6
dur = max(0.08, round(dur, 3))
# ---- 幅度与中心点 ----
span = (h if vertical else w) * float(distance_ratio) * prof["distance"]
if j:
span *= 1 + random.uniform(-j, j)
span = max(0.08, min(span, (h if vertical else w) * 0.92))
# 起点不在正中:人不会每次都在屏幕同一条线上划
if vertical:
cx = w * (0.5 + prof["x_bias"] + (random.uniform(-j, j) * 0.6 if j else 0))
cx = int(max(w * 0.10, min(cx, w * 0.90)))
cy = h * 0.5
if j:
cy += h * random.uniform(-j, j) * 0.5
sign = -1 if direction == "up" else 1
p0 = (cx, int(max(h * 0.03, min(cy - sign * span / 2, h * 0.97))))
p2 = (cx, int(max(h * 0.03, min(cy + sign * span / 2, h * 0.97))))
else:
cy = int(h * (0.5 + prof["x_bias"] + (random.uniform(-j, j) * 0.6 if j else 0)))
cy = int(max(h * 0.10, min(cy, h * 0.90)))
cx = w * 0.5
if j:
cx += w * random.uniform(-j, j) * 0.5
sign = -1 if direction == "left" else 1
p0 = (int(max(w * 0.03, min(cx - sign * span / 2, w * 0.97))), cy)
p2 = (int(max(w * 0.03, min(cx + sign * span / 2, w * 0.97))), cy)
if not humanize:
# 老行为:正中直线、无抖动(点与点之间由设备等分插值)
if vertical:
s, e = (0.8, 0.2) if direction == "up" else (0.2, 0.8)
base = (int(w * 0.5), int(h * s), int(w * 0.5), int(h * e))
else:
s, e = (0.8, 0.2) if direction == "left" else (0.2, 0.8)
base = (int(w * s), int(h * 0.5), int(w * e), int(h * 0.5))
d.swipe(*base, random.uniform(float(duration_min), float(duration_max)))
return {"duration": dur, "from": base[:2], "to": base[2:], "human": False}
# ---- 弧线:控制点在中点、垂直于滑动方向偏移 ----
amp = span * 0.05 * prof["curve"]
if j:
amp *= random.uniform(0.3, 1.0)
amp *= random.choice((-1, 1)) # 这次往左弯还是往右弯
else:
# jitter=0 时不引入任何随机:弯向由设备性格决定,便于对照排障
amp *= -1 if prof["curve"] < 1.0 else 1
mx, my = (p0[0] + p2[0]) / 2, (p0[1] + p2[1]) / 2
if vertical:
ctrl = (mx + amp, my)
else:
ctrl = (mx, my + amp)
# 点数刻意压到 4:`swipe_points` 在慢设备上**每多一个点就多约 1s**
# (实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间
# 插值 70 步,4 个点的贝塞尔已经足够弯
pts = _bezier(p0, ctrl, p2, 4)
if len(pts) < 2: # 幅度太小时退化成直线
pts = [p0, p2]
try:
d.swipe_points([[x, y] for x, y in pts], dur)
except Exception as e: # 老设备/agent 不支持曲线就退回直线
_log.warning("swipe_points 不可用,退回直线滑动: %s", e)
d.swipe(p0[0], p0[1], p2[0], p2[1], dur)
return {"duration": dur, "from": p0, "to": p2, "curve": amp, "human": True}
def pace(serial, seconds, enabled=True):
"""按本设备节奏微调一个停顿(wait 步骤可选启用)。enabled=False 原样返回。"""
if not enabled:
return float(seconds)
return float(seconds) * profile(serial)["pause"]
+193
View File
@@ -16,7 +16,9 @@
文件按 10MB 滚动,保留 5 个历史文件。 文件按 10MB 滚动,保留 5 个历史文件。
""" """
import os import os
import re
import logging import logging
from collections import deque
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
_LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs") _LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
@@ -31,6 +33,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"
@@ -82,3 +86,192 @@ def log_print(msg, level="info", module="core"):
"""print 的替代品,转发到 logging。""" """print 的替代品,转发到 logging。"""
log = get_logger(module) log = get_logger(module)
getattr(log, level, log.info)(msg) getattr(log, level, log.info)(msg)
# ================== 日志读取(「日志」页的筛选 / 下载) ==================
#
# 读日志的代码放这里而不是 web 层:文件命名与滚动规则归本模块管
# (_MODULE_FILES + RotatingFileHandler 的 backupCount),**白名单校验必须和
# 写入侧用同一份事实**,否则改了文件名就会漏掉一处,变成目录穿越。
# 行格式与 _FORMAT 对应:2026-09-16 00:27:11 [INFO] [web.notify] 消息
_LINE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
r"\[([A-Z]+)\] \[([^\]]*)\] ?(.*)$")
_LEVEL_ORDER = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
# 单次读取的行数上限:文件本身被限在 10MB(约 10 万行),这里再兜一层,
# 万一将来有人调大 maxBytes,也不会把内存吃爆。
_MAX_SCAN_LINES = 500000
_MODULE_LABELS = {
"core.log": "核心(adb/调度)",
"task.log": "任务执行",
"web.log": "Web/AI",
"action.log": "动作执行",
"notify.log": "通知发送",
}
def _rotation_files(base):
"""基础名 + 磁盘上实际存在的滚动副本(core.log → core.log.1/.2…)。
RotatingFileHandler 的备份编号**越大越旧**,所以 base 最新,接着 .1/.2…
"""
names = []
if os.path.exists(os.path.join(_LOG_DIR, base)):
names.append(base)
i = 1
while i <= 99 and os.path.exists(os.path.join(_LOG_DIR, f"{base}.{i}")):
names.append(f"{base}.{i}")
i += 1
return names
def list_log_files():
"""可查看的日志文件(模块文件 + 滚动历史),最近写入的排前面。
`file` 参数只能取这里的 name(白名单),杜绝 `../../` 之类的路径穿越。
"""
out = []
for base in sorted(set(_MODULE_FILES.values())):
label = _MODULE_LABELS.get(base, base)
for name in _rotation_files(base):
try:
st = os.stat(os.path.join(_LOG_DIR, name))
except OSError:
continue
rotated = name != base
out.append({
"name": name,
"label": f"{label} · {name}" + ("(历史)" if rotated else ""),
"module": base,
"size": st.st_size,
"mtime": int(st.st_mtime),
"rotated": rotated,
})
out.sort(key=lambda f: (-f["mtime"], f["name"]))
return out
def norm_ts(value, end=False):
"""把前端的时间输入归一成 'YYYY-MM-DD HH:MM:SS'(可比字符串)。
接受 datetime-local 的 'YYYY-MM-DDTHH:MM'、'YYYY-MM-DD HH:MM' 和纯日期;
纯日期按整天算(end=True 取 23:59:59,否则 00:00:00)。
"""
s = (value or "").strip().replace("T", " ")
if not s:
return ""
if len(s) == 10: # 只给了日期 → 按整天
return s + (" 23:59:59" if end else " 00:00:00")
if len(s) == 16: # YYYY-MM-DD HH:MM
return s + (":59" if end else ":00")
return s[:19]
def _iter_filtered(fh, keyword, min_level, since, until, stats=None):
"""逐行读文件,产出命中的 {ts, level, module, msg, cont}。
过滤口径:
- 时间/级别用**继承值**——续行(traceback 的缩进行等)本身没有前缀,
继承上一条带前缀的行,这样按 ERROR 筛选时整段堆栈不会被拆散;
- 关键字匹配本行原文(续行也参与),大小写不敏感。
stats(可选 dict)里回填 scanned:本次实际读了多少行(用于告诉用户
"是读到文件开头了,还是被行数上限截断的")。
"""
kw = (keyword or "").strip().lower()
floor = _LEVEL_ORDER.get((min_level or "").upper(), 0)
cur_ts = cur_level = cur_module = ""
for raw in fh:
if stats is not None:
stats["scanned"] += 1
line = raw.rstrip("\r\n")
m = _LINE_RE.match(line)
if m:
cur_ts, cur_level, cur_module, msg = m.groups()
cont = False
else:
# 续行(traceback、被切成多行的长消息):沿用上一条的元信息
msg, cont = line, True
if since and (not cur_ts or cur_ts < since):
continue
if until and (not cur_ts or cur_ts > until):
continue
if floor and _LEVEL_ORDER.get(cur_level, 0) < floor:
continue
if kw and kw not in line.lower():
continue
yield {"ts": cur_ts, "level": cur_level, "module": cur_module,
"msg": msg, "cont": cont}
def query_log(name, keyword="", min_level="", since="", until="",
limit=300, max_scan=_MAX_SCAN_LINES):
"""取日志文件里**最近** limit 条满足条件的行。
返回 dict:
rows — [{ts, level, module, msg, cont}],保持文件原顺序(旧→新)
matched — 命中总数(可能大于 len(rows):更早的命中没返回)
scanned — 实际读了多少行(达到 max_scan 说明是被上限截断的)
truncated — matched > len(rows),即"还有更早的命中没显示"
error — 出错时的原因(如文件不存在)
正序读整文件 + deque 保留最后 N 条:文件被 RollingFileHandler 限在 10MB
(约 10 万行),整读一次百毫秒级;换来的是续行能正确继承时间戳——倒着
读的写法在遇到 traceback 时必须把续行攒着等前面那行,容易出错。
"""
# 白名单:`name` 必须来自 list_log_files(),否则一律拒绝(防目录穿越)
if name not in {f["name"] for f in list_log_files()}:
return {"rows": [], "matched": 0, "scanned": 0, "truncated": False,
"error": "日志文件不存在或不可读取"}
since, until = norm_ts(since), norm_ts(until, end=True)
try:
keep = max(1, min(int(limit or 300), 5000))
except (TypeError, ValueError):
keep = 300
rows, matched, stats = deque(maxlen=keep), 0, {"scanned": 0}
try:
with open(os.path.join(_LOG_DIR, name), encoding="utf-8",
errors="replace") as fh:
for row in _iter_filtered(fh, keyword, min_level, since, until, stats):
matched += 1
rows.append(row)
if stats["scanned"] >= max_scan:
break
except OSError as e:
return {"rows": [], "matched": 0, "scanned": 0, "truncated": False,
"error": f"读取失败: {e}"}
return {"rows": list(rows), "matched": matched, "scanned": stats["scanned"],
"truncated": matched > len(rows), "error": ""}
def read_log_text(name, keyword="", min_level="", since="", until="",
limit=200000):
"""导出用的整段文本:命中行按原格式拼回去(下载按钮)。
没给任何条件时直接读整个文件(含滚动历史由调用方选定的那一个)。
"""
if name not in {f["name"] for f in list_log_files()}:
return None, "日志文件不存在或不可读取"
since, until = norm_ts(since), norm_ts(until, end=True)
any_filter = bool((keyword or "").strip() or min_level or since or until)
path = os.path.join(_LOG_DIR, name)
try:
with open(path, encoding="utf-8", errors="replace") as fh:
if not any_filter:
return fh.read(), ""
buf, n = [], 0
for row in _iter_filtered(fh, keyword, min_level, since, until):
if row["cont"]:
buf.append(row["msg"]) # 续行原样输出
else:
buf.append(f"{row['ts']} [{row['level']}] "
f"[{row['module']}] {row['msg']}")
n += 1
if n >= limit:
buf.append(f"...(已达导出上限 {limit} 行,请缩小时间范围)")
break
return "\n".join(buf) + ("\n" if buf else ""), ""
except OSError as e:
return None, f"读取失败: {e}"
+34
View File
@@ -369,6 +369,40 @@ class DeviceInstallLog(db.Model):
created_at = db.Column(db.String(20), default="") created_at = db.Column(db.String(20), default="")
class TaskStepLog(db.Model):
"""任务步骤明细:每一次步骤执行的落库记录(「日志 → 步骤明细」页)。
与 `logs/task.log` 的分工:文本日志是**排障时的原始现场**(什么都往里写、
10MB 滚动),本表是**结构化的一份**——设备/任务/步骤/结果/耗时都是列,
所以能按设备、任务、时间、结果过滤和统计,文本日志只能 grep。
写入方是 `core/step_log.py` 的专用写线程(异步批量落库),**任务线程不直接
写库**:一次运行可能上万步,每步一次 INSERT 会拖慢热路径。
保留期由 `core/step_log.py` 的清理任务控制(默认 14 天,见该模块常量)。
"""
__tablename__ = "task_step_log"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
run_id = db.Column(db.String(24), default="", index=True) # 一次运行=设备×任务×第几次尝试
job_id = db.Column(db.String(32), default="")
job_name = db.Column(db.String(120), default="")
serial = db.Column(db.String(120), default="")
device_name = db.Column(db.String(80), default="")
step_path = db.Column(db.String(32), default="") # 嵌套位置,如 "2.1.3"
step_label = db.Column(db.String(120), default="")
step_type = db.Column(db.String(40), default="") # click_el / loop / input_text …
selector = db.Column(db.String(300), default="") # 元素选择器(长选择器截断)
result = db.Column(db.String(16), default="") # ok/skip/miss/error/unknown/cap
detail = db.Column(db.String(500), default="") # 异常消息、跳过原因等
duration_ms = db.Column(db.Integer, default=0)
created_at = db.Column(db.String(20), default="", index=True)
__table_args__ = (
db.Index("ix_step_log_serial_ts", "serial", "created_at"),
db.Index("ix_step_log_job_ts", "job_id", "created_at"),
)
class AgentConversation(db.Model): class AgentConversation(db.Model):
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。""" """AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
__tablename__ = "agent_conversation" __tablename__ = "agent_conversation"
+1118
View File
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
"""通知事件目录(**唯一真相**)。
约定:
- **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", "skipped",
"failed_devices", "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", "model", "job_id", "job_name", "attempt", "duration_s"],
"某台设备上的任务最终成功。**只在目标设备只有 1 台时发送**——多设备批次看"
"「任务批次结束」就够了,13 台设备就是 13 条刷屏", agg_key=_BY_JOB),
_e("task.device.failed", "设备任务失败", "任务·单设备",
["serial", "device_name", "model", "job_id", "job_name", "attempts",
"cause", "msg"],
"某台设备上的任务最终失败(带真实原因与型号)", agg_key=_BY_JOB, recommend=True),
_e("task.device.offline", "设备离线放弃", "任务·单设备",
["serial", "device_name", "model", "job_id", "job_name", "error"],
"设备离线,不重试直接失败", agg_key=_BY_JOB, recommend=True),
_e("task.device.error", "单次尝试异常", "任务·单设备",
["serial", "device_name", "model", "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", "model", "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.patrol.hit", "任务巡检命中", "业务",
["title", "message", "patrol_name", "check_label", "detail", "action_label",
"job_id", "job_name", "serial", "device_name", "model"],
"任务里配置的「公共巡检」命中(熄屏/元素/前台…),已执行动作——"
"标题正文可在巡检项里自己写,支持 {device}/{job}/{time}/{app}/{screen}",
agg_window=0, recommend=True),
_e("task.notify.custom", "任务自定义通知", "业务",
["title", "message", "job_id", "job_name", "serial", "device_name", "model"],
"任务里的「发通知」步骤被触发——标题与正文由任务自己写,支持 "
"{device}/{serial}/{job}/{time}/{app}/{screen} 占位符", agg_window=0,
recommend=True),
_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
+110
View File
@@ -0,0 +1,110 @@
"""任务内**公共巡检**:任务运行期间按间隔穿插检查设备状态,命中就动作 + 通知。
和步骤编辑器的关系(重要,别混):
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|---|---|---|
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器里单独一块 |
| 执行方式 | 顺序执行 | **穿插**:每执行完一步(以及长等待的每个分片)看一眼"哪个巡检到点了" |
| 适合什么 | 一段固定流程 | 全程都要盯的守护条件(熄屏就点亮、掉到桌面就通知) |
为什么用穿插而不是另起线程:不需要并发模型,不会和主流程抢屏幕(否则巡检点屏幕、
主流程也在点,动作会互相打断)。代价是巡检精度受步长影响——某一步卡 30 秒,
巡检最多晚 30 秒;这也是刻意的取舍(见 doc/TASK_DEV.md §4.5)。
配置结构(`params.watchers` 数组,每项):
```json
{"name": "熄屏点亮", "enabled": true, "interval": 60,
"check": "screen_off", // 见 CHECKS
"selector_type": "xpath", "selector_value": "", // 元素类检查才用
"action": "screen_on", // 见 ACTIONS
"notify": true, "title": "{device} 熄屏了",
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
"cooldown": 300} // 命中后多久内不再重复动作/通知(防刷屏)
```
"""
from core.logger import get_logger
from core.u2_helper import screen_is_on, current_package
_log = get_logger("task.generic")
# 检查项:need 说明该项还要填什么(selector=元素选择器 / package=包名)
CHECKS = {
"screen_off": {"label": "屏幕熄灭", "need": ""},
"screen_on": {"label": "屏幕亮着", "need": ""},
"element_exists": {"label": "元素存在", "need": "selector"},
"element_missing": {"label": "元素不存在", "need": "selector"},
"foreground_is": {"label": "前台是该App", "need": "package"},
"foreground_not": {"label": "前台不是该App", "need": "package"},
}
ACTIONS = {
"none": {"label": "只发通知,不动设备"},
"screen_on": {"label": "点亮屏幕"},
"screen_off": {"label": "熄灭屏幕"},
"stop_self": {"label": "停止本设备任务"},
}
# 通知正文里可用的占位符(前端提示、文档、渲染三处共用一份,避免漂移)
PLACEHOLDERS = ("device", "serial", "job", "time", "app", "screen")
def _element_exists(d, sel_type, sel_val):
"""元素是否存在(不等超时,立即判定)。一次 UI dump 的代价,慢设备上可能要几秒。"""
if sel_type == "xpath":
return bool(d.xpath(sel_val).exists())
return bool(d(**{sel_type: sel_val}).exists())
def evaluate(d, spec):
"""跑一次检查,返回 (是否命中, 一句人话说明)。异常由调用方兜。"""
check = str(spec.get("check") or "")
val = str(spec.get("selector_value") or "").strip()
if check in ("screen_off", "screen_on"):
state = screen_is_on(d)
if state is None:
return False, "屏幕状态读不到"
hit = (not state) if check == "screen_off" else state
return hit, f"屏幕{'亮着' if state else '熄灭'}"
if check in ("element_exists", "element_missing"):
if not val:
return False, "没填选择器"
found = _element_exists(d, spec.get("selector_type") or "xpath", val)
return (found if check == "element_exists" else not found), \
f"元素{'存在' if found else '不存在'}"
if check in ("foreground_is", "foreground_not"):
if not val:
return False, "没填包名"
cur = current_package(d)
return (cur == val) if check == "foreground_is" else (cur != val), \
f"前台={cur or '未知'}"
return False, f"未知检查项 {check}"
def act(d, spec, worker=None):
"""执行命中后的动作,返回一句人话(没动作返回空串)。"""
action = str(spec.get("action") or "none")
if action == "screen_on":
d.unlock() # 与 screen_on 步骤同语义:唤醒 + 解锁
return "已点亮屏幕"
if action == "screen_off":
d.screen_off()
return "已熄灭屏幕"
if action == "stop_self":
if worker is not None:
worker.stop() # 置停止位,主流程下一步就退出
return "已停止本设备任务"
return ""
def check_label(check):
return (CHECKS.get(check) or {}).get("label", check)
def action_label(action):
return (ACTIONS.get(action) or {}).get("label", action)
+247
View File
@@ -0,0 +1,247 @@
"""任务步骤明细:异步落库(专用写线程)+ 保留期清理。
为什么不让任务线程直接写库:步骤执行是**热路径**(一次运行可能上万步),每步
一次 INSERT 要建会话、等 MySQL 往返,还会和业务事务抢连接。这里改成「专用写
线程 + 有界队列」:任务线程只做一次 put_nowait(微秒级),落库、重排、批量都
在写线程里做。
铁律(与 `core/notifier.py` 一致):
- `record()` 零阻塞、零 DB、**绝不抛异常** —— 记录绝不影响任务执行;
- 落库失败只写日志、丢弃该批,**不重排**(不为补数据把内存撑爆)。
线程用裸 `threading.Thread(daemon=True)`(与项目其它后台线程一致;线程池的
非 daemon 线程会在退出时被 atexit join 卡住)。
"""
import queue
import threading
import time
from core.logger import get_logger
_log = get_logger("task.step")
# 一次运行最多记录多少条:防「2 小时无限循环」这类任务把表写爆。
# 达到上限后本次运行不再逐条记,只补一条 cap 说明行。
MAX_ROWS_PER_RUN = 2000
# 保留天数:每日清理任务删除更早的记录。**这个值直接决定备份包体积**
# (task_step_log 会进整库备份,见 doc/DATA_MODEL.md §6),改大前先想清楚。
KEEP_DAYS = 14
# 队列上限与批量:队列满 = 丢弃并计数(宁可丢明细,也不能拖住任务线程)
_QUEUE_MAX = 5000
_BATCH = 200
_FLUSH_INTERVAL = 1.0
_app = None
_q = queue.Queue(maxsize=_QUEUE_MAX)
_writer = None
_stop = threading.Event()
_stats = {"queued": 0, "written": 0, "dropped": 0, "failed": 0}
# ================== 生命周期 ==================
def init_app(app):
"""启动写线程(web_server 启动时调用;重复调用安全)。"""
global _app, _writer
_app = app
if _writer is not None and _writer.is_alive():
return
_stop.clear()
_writer = threading.Thread(target=_writer_loop, name="step-log-writer",
daemon=True)
_writer.start()
_log.info(f"步骤明细写线程已启动(保留 {KEEP_DAYS} 天,单次运行上限 "
f"{MAX_ROWS_PER_RUN} 条)")
def shutdown(timeout=3.0):
"""退出前停写线程并尽量排空队列(超时就算了,明细不是关键数据)。"""
_stop.set()
if _writer is not None:
_writer.join(timeout)
def stats():
"""本进程内的计数(排障用:看有没有在丢记录)。"""
return dict(_stats, queue_size=_q.qsize())
# ================== 写入侧 ==================
def record(run_id="", job_id="", job_name="", serial="", device_name="",
step_path="", step_label="", step_type="", selector="",
result="", detail="", duration_ms=0):
"""记录一次步骤执行。**任务线程直接调用**——零阻塞、零 DB、不抛异常。
队列满时丢弃并计数(dropped),不会阻塞调用方。
"""
try:
row = {
"run_id": (run_id or "")[:24],
"job_id": (job_id or "")[:32],
"job_name": (job_name or "")[:120],
"serial": (serial or "")[:120],
"device_name": (device_name or "")[:80],
"step_path": (step_path or "")[:32],
"step_label": (step_label or "")[:120],
"step_type": (step_type or "")[:40],
"selector": (selector or "")[:300],
"result": (result or "")[:16],
"detail": (detail or "")[:500],
"duration_ms": int(duration_ms or 0),
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}
_q.put_nowait(row)
_stats["queued"] += 1
except queue.Full:
_stats["dropped"] += 1
except Exception: # 截断/类型异常都不该冒泡到任务线程
_stats["dropped"] += 1
def _collect():
"""取一批待写记录:最多等 _FLUSH_INTERVAL,凑够 _BATCH 就走。"""
rows = []
try:
rows.append(_q.get(timeout=_FLUSH_INTERVAL))
except queue.Empty:
return rows
while len(rows) < _BATCH:
try:
rows.append(_q.get_nowait())
except queue.Empty:
break
return rows
def _insert(rows):
"""批量落库。失败只记日志——明细丢了可以接受,任务不能被拖住。"""
if not rows or _app is None:
return
from core.models import TaskStepLog, db
try:
with _app.app_context():
db.session.execute(db.insert(TaskStepLog), rows)
db.session.commit()
_stats["written"] += len(rows)
except Exception as e:
_stats["failed"] += len(rows)
try:
db.session.rollback()
except Exception:
pass
_log.error(f"步骤明细落库失败(丢弃 {len(rows)} 条): {e}")
def _writer_loop():
while not _stop.is_set():
batch = _collect()
if batch:
_insert(batch)
# 退出前再排空一次,尽量不丢最后几条
while True:
batch = _collect()
if not batch:
break
_insert(batch)
# ================== 查询(给 API 用,在请求线程里跑) ==================
def query(serial="", job_id="", result="", run_id="", keyword="",
since="", until="", limit=200, offset=0):
"""按条件查明细,返回 (rows, total)。**倒序**(最近的在前)。"""
from sqlalchemy import or_
from core.models import TaskStepLog
q = TaskStepLog.query
if serial:
q = q.filter(TaskStepLog.serial == serial)
if job_id:
q = q.filter(TaskStepLog.job_id == job_id)
if result:
q = q.filter(TaskStepLog.result == result)
if run_id:
q = q.filter(TaskStepLog.run_id == run_id)
if since:
q = q.filter(TaskStepLog.created_at >= since)
if until:
q = q.filter(TaskStepLog.created_at <= until)
if keyword:
kw = f"%{keyword}%"
q = q.filter(or_(TaskStepLog.detail.like(kw),
TaskStepLog.step_label.like(kw),
TaskStepLog.step_type.like(kw),
TaskStepLog.selector.like(kw),
TaskStepLog.job_name.like(kw)))
total = q.count()
rows = (q.order_by(TaskStepLog.id.desc())
.offset(max(0, int(offset or 0)))
.limit(max(1, min(int(limit or 200), 2000)))
.all())
return rows, total
def runs(serial="", job_id="", since="", until="", limit=100):
"""按 run_id 归组的一次运行概览(哪次运行失败、失败几步)。
用一次 GROUP BY 查出来,避免前端按明细自己聚合。
"""
from sqlalchemy import case, func
from core.models import TaskStepLog, db
q = db.session.query(
TaskStepLog.run_id,
func.min(TaskStepLog.created_at).label("started_at"),
func.max(TaskStepLog.created_at).label("ended_at"),
func.max(TaskStepLog.job_name).label("job_name"),
func.max(TaskStepLog.job_id).label("job_id"),
func.max(TaskStepLog.serial).label("serial"),
func.max(TaskStepLog.device_name).label("device_name"),
func.count(TaskStepLog.id).label("steps"),
func.sum(case((TaskStepLog.result.in_(("error", "miss", "unknown")), 1),
else_=0)).label("failures"),
)
if serial:
q = q.filter(TaskStepLog.serial == serial)
if job_id:
q = q.filter(TaskStepLog.job_id == job_id)
if since:
q = q.filter(TaskStepLog.created_at >= since)
if until:
q = q.filter(TaskStepLog.created_at <= until)
q = (q.filter(TaskStepLog.run_id != "")
.group_by(TaskStepLog.run_id)
.order_by(func.max(TaskStepLog.id).desc())
.limit(max(1, min(int(limit or 100), 500))))
return q.all()
def purge_old(keep_days=None, batch=5000):
"""删除超过保留期的明细。返回删除行数。
分批删(先取 id 再按 id 删,**不用 DELETE ... LIMIT**——SQLite 默认编译
选项不支持它),避免一条长事务把表锁住。
"""
from core.models import TaskStepLog, db
days = KEEP_DAYS if keep_days is None else int(keep_days)
cutoff = time.strftime("%Y-%m-%d %H:%M:%S",
time.localtime(time.time() - days * 86400))
deleted = 0
try:
while True:
ids = [r.id for r in db.session.query(TaskStepLog.id)
.filter(TaskStepLog.created_at < cutoff).limit(batch).all()]
if not ids:
break
deleted += db.session.query(TaskStepLog) \
.filter(TaskStepLog.id.in_(ids)) \
.delete(synchronize_session=False)
db.session.commit()
except Exception as e:
db.session.rollback()
_log.error(f"步骤明细清理失败(已删 {deleted} 条): {e}")
return deleted
if deleted:
_log.info(f"步骤明细清理: 删除 {deleted} 条(保留 {days} 天)")
return deleted
+7
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")
@@ -55,6 +56,7 @@ TABLE_LABELS = {
"pending_device": "待连接设备", "agent_conversation": "AI 会话", "pending_device": "待连接设备", "agent_conversation": "AI 会话",
"agent_experience": "经验库", "experience_audit": "经验巡检", "agent_experience": "经验库", "experience_audit": "经验巡检",
"agent_action": "动作库", "device_install_log": "设备端安装记录", "agent_action": "动作库", "device_install_log": "设备端安装记录",
"task_step_log": "任务步骤明细",
} }
_STAGE_TTL = 1800 # 导入暂存有效期(秒) _STAGE_TTL = 1800 # 导入暂存有效期(秒)
@@ -547,6 +549,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 +573,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
+211 -13
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,81 @@ _log = get_logger("core.tm")
_START_STAGGER_SEC = 0.2 _START_STAGGER_SEC = 0.2
def _dev_model(serial, tracker=None):
"""设备型号:优先用批次开始时查好的设备池快照,退回 worker 上报的状态表。
为什么优先快照:worker 每次启动都会 `_update_status(model=…)`,但那次采集
(`d.info()`)可能超时/失败 → 型号是空;而设备池的型号是后台统一采集的、
稳定得多。快照还有个好处:不查库(通知路径上不碰 DB)。
"""
if tracker is not None:
m = (tracker.models or {}).get(serial)
if m:
return m
with _WORKERS_LOCK:
return _WORKERS.get(serial, {}).get("model") or ""
def _dev_fail_note(serial, dname, tracker=None):
"""失败设备的一行说明:`名字(型号):原因`——批次汇总里直接列出来。
型号同上用快照;原因取 worker 上报的 last_error(刚写进去,还在)。
"""
with _WORKERS_LOCK:
err = _WORKERS.get(serial, {}).get("last_error") or ""
cause = err.strip().splitlines()[0] if err.strip() else ""
model = _dev_model(serial, tracker)
head = f"{dname}({model})" if model else str(dname)
return f"{head}:{cause[:60]}" if cause else head
class _BatchTracker:
"""一次任务批次的收尾统计:**所有设备都出结果**后发一条 `task.batch.finished`。
为什么不每台设备各发一条"批次结束":100 台设备就是 100 条刷屏。这里只计数,
归零那一刻发一条汇总(锁外发,见 done())。
每台设备的结果由 `_run_with_retry` 的 finally 调一次 `done(serial, outcome)`——
放在 finally 里是为了保证"任何一个 return 分支都会被计数一次",不会漏也不会重。
"""
def __init__(self, job, total, names=None, models=None):
self.job = job
self.total = total
# serial -> 设备名(通知里显示名字而不是裸地址);取不到就是空
self.names = dict(names or {})
# serial -> 型号(批次开始时从设备池查一次带下来,通知里直接用)
self.models = dict(models or {})
self._lock = threading.Lock()
self._left = total
self._stats = Counter()
self._failed = [] # 失败设备明细(型号 + 原因),汇总里有失败时列出
self._t0 = time.time()
def done(self, serial, outcome="failed", detail=""):
"""登记一台设备的结果(success/failed/stopped/skipped)。
detail 只对 failed 有意义("设备名(型号):原因"),最多留 5 条 ——
批次汇总里列出来,用户不用再去翻日志。
"""
with self._lock:
self._left -= 1
self._stats[outcome] += 1
if outcome == "failed" and detail and len(self._failed) < 5:
self._failed.append(detail)
left = self._left
s = dict(self._stats)
fails = list(self._failed)
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),
failed_devices=fails,
duration_s=int(time.time() - self._t0))
def _in_run_window(schedule, now=None): def _in_run_window(schedule, now=None):
"""是否在当前运行窗口内。 """是否在当前运行窗口内。
@@ -633,6 +710,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 +743,43 @@ 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, models = {}, {}
try:
with self._db():
rows = device_pool.list_devices()
names = {d["serial"]: (d.get("name") or "") for d in rows}
models = {d["serial"]: (d.get("model") or "") for d in rows}
except Exception as e:
_log.warning(f"读取设备名/型号失败(通知里将显示地址): {e}")
tracker = _BatchTracker(job, len(serials), names, models)
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 +790,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})")
return 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
# 同一 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 = "设备被其他任务占用"
preempt = True else:
if not preempted_job: preempt = True
preempted_job = cur.get("job_id") if not preempted_job:
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 +851,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,8 +864,14 @@ class TaskManager:
max_attempts=max_attempts) max_attempts=max_attempts)
worker = None worker = None
t_attempt = time.time() # 单次 attempt 耗时(通知里带)
# 本次运行的上下文:步骤明细按 run_id 归组,把同一设备同一次尝试
# 执行的步骤串起来(重试会生成新的 run_id,两次尝试不混在一起)
run_ctx = {"run_id": uuid.uuid4().hex[:12], "job_id": job.id,
"job_name": job.name, "device_name": dname,
"attempt": attempt}
try: try:
worker = task.create_worker(serial, job.params) worker = task.create_worker(serial, job.params, ctx=run_ctx)
with self._lock: with self._lock:
self._running[serial]["worker"] = worker self._running[serial]["worker"] = worker
_log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)") _log.info(f"{serial} 开始任务 {job.name} (第{attempt}/{max_attempts}次)")
@@ -756,12 +887,40 @@ 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"
# 单设备成功:**只在"这一批本来就只有一台设备"时发**。
# 多设备批次里逐台报成功没有信息量(批次汇总里已有成功台数),
# 13 台就是 13 条刷屏;失败仍然逐台发——那是少数且要知道是哪台。
if tracker is None or tracker.total <= 1:
notifier.notify("task.device.success", serial=serial,
device_name=dname,
model=_dev_model(serial, tracker),
job_id=job.id, job_name=job.name,
attempt=attempt,
duration_s=int(time.time() - t_attempt))
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
if worker is not None and worker.stopped():
# 任务**自己**停的(步骤里的「停止本设备」):不是失败,也不重试。
# 判据是"worker 置了停止位但 _stop_requested 里没有"——上面那个
# 分支已经排除了用户/调度停止的情况。
_log.info(f"{serial} 任务内主动停止本设备 (job={job.name})")
outcome = "stopped"
notifier.notify("task.device.stopped", serial=serial,
device_name=dname,
model=_dev_model(serial, tracker),
job_id=job.id, job_name=job.name,
attempt=attempt, phase="self")
return
_log.warning(f"{serial} 任务未成功(status={st})") _log.warning(f"{serial} 任务未成功(status={st})")
except DeviceOfflineError as e: except DeviceOfflineError as e:
# 设备掉线,立即放弃,不重试 # 设备掉线,立即放弃,不重试
@@ -770,30 +929,49 @@ 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,
model=_dev_model(serial, tracker),
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,
model=_dev_model(serial, tracker),
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 +983,40 @@ 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,
model=_dev_model(serial, tracker), 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,
detail=_dev_fail_note(serial, dname, tracker)
if outcome == "failed" else "")
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):
+29
View File
@@ -5,6 +5,7 @@
""" """
import time import time
import random import random
import re
from core.logger import get_logger from core.logger import get_logger
@@ -65,6 +66,34 @@ def random_sleep(min_s, max_s):
time.sleep(random.uniform(min_s, max_s)) time.sleep(random.uniform(min_s, max_s))
def screen_is_on(d):
"""屏幕是否亮着。返回 True/False,取不到返回 None(三态,别当 False 用)。
为什么不用 `d.info`:它在部分设备上一次要**十几秒**(见 doc/backlog/TODO.md);
`dumpsys power` 经 atx-agent 跑只要 ~0.3s,且 `mWakefulness` 字段全机型都有
(MIUI 的 `dumpsys display` 反而没有 mScreenState)。
"""
try:
out = d.shell("dumpsys power")
out = getattr(out, "output", None) or str(out)
m = re.search(r"mWakefulness=(\w+)", out)
if m:
return m.group(1).lower() != "asleep"
except Exception as e:
_log.warning(f"读取屏幕状态失败: {e}")
return None
def current_package(d):
"""当前前台 App 包名;取不到返回空串。"""
try:
cur = d.app_current() or {}
return cur.get("package") or ""
except Exception as e:
_log.warning(f"读取前台 App 失败: {e}")
return ""
def safe_click(el, timeout=1): def safe_click(el, timeout=1):
"""安全点击:元素存在才点,不抛异常。返回是否点击成功。""" """安全点击:元素存在才点,不抛异常。返回是否点击成功。"""
try: try:
+1 -1
View File
@@ -184,7 +184,7 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage,
从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束: 从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
- **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效) - **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
- 白名单步骤类型(18 种去掉 `click_xy`、`keep_screen`) - 白名单步骤类型(20 种去掉 `click_xy`、`keep_screen`)
- 每类型有必填参数校验(如 `click` 必须有选择器) - 每类型有必填参数校验(如 `click` 必须有选择器)
- 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步 - 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
- 保存时服务端**再校验一次**,含坐标的提交直接 400 - 保存时服务端**再校验一次**,含坐标的提交直接 400
+1 -1
View File
@@ -9,7 +9,7 @@
平台已有两套能力,但互不相通: 平台已有两套能力,但互不相通:
- **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。 - **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。
- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…18 种节点),在编辑器里拖拽编排、单步试跑、定时调度。 - **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…20 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。 目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。
+77 -4
View File
@@ -139,7 +139,12 @@
| POST | `/api/users` | Admin | 新建用户 | | POST | `/api/users` | Admin | 新建用户 |
| PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) | | PUT | `/api/users/<int:uid>` | Admin | 更新用户(改密/权限/管理员) |
| DELETE | `/api/users/<int:uid>` | Admin | 删除用户 | | DELETE | `/api/users/<int:uid>` | Admin | 删除用户 |
| GET | `/api/logs` | G | 读日志文件尾部 N 行 | | GET | `/api/logs` | G | 读日志(关键字/级别/时间过滤,返回结构化行) |
| GET | `/api/logs/download` | G | 下载日志(同样支持过滤;无过滤即整个文件) |
| GET | `/api/step_logs` | G | 任务步骤明细(按设备/任务/结果/时间/关键字过滤,分页) |
| GET | `/api/step_logs/runs` | G | 按 `run_id` 归组的一次运行概览(几步、失败几步) |
| GET | `/api/step_logs/filters` | G | 步骤明细的筛选项(明细里出现过的设备/任务 + 结果枚举) |
| GET | `/api/step_logs/download` | G | 导出步骤明细 CSV(带 UTF-8 BOM,Excel 直接打开) |
### 2.5 devices(`web/devices_api.py`) ### 2.5 devices(`web/devices_api.py`)
@@ -238,7 +243,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 +342,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":"…"}` | 结束定位 |
@@ -599,7 +620,59 @@
| `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 | | `POST /api/users` | Admin | `{"username","password","is_admin"?,"perms"?}` | 用户名/密码空、重名 → 400 |
| `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 | | `PUT /api/users/<int:uid>` | Admin | `{"password"?,"is_admin"?,"perms"?}` | 取消最后一个管理员 → 400;不存在 404 |
| `DELETE /api/users/<int:uid>` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 | | `DELETE /api/users/<int:uid>` | Admin | — | 删 `admin`/删自己/删最后一个管理员 → 400 |
| `GET /api/logs?file=core.log&lines=300` | G | — | 返回日志尾部 + 可选文件清单 | | `GET /api/logs?file=core.log&lines=300` | G | `q` 关键字、`level` 最低级别、`since`/`until` 时间 | 见下 §10.1 |
| `GET /api/logs/download?file=core.log` | G | 同上(参数一致) | 以附件返回 `.txt`,文件名带时间戳 |
#### 10.1 日志查询(`GET /api/logs`)
参数:
| 参数 | 说明 |
|------|------|
| `file` | 日志文件名,**白名单校验**(只能取接口返回的 `files[].name`,含 `.1/.2` 滚动历史);非法值返回 `ok:false` |
| `lines` | 返回**最近**多少条命中(默认 300,上限 5000) |
| `q` | 关键字,大小写不敏感,匹配整行原文 |
| `level` | 最低级别:`ERROR` / `WARNING` / `INFO` / `DEBUG`(含以上);留空为全部 |
| `since` / `until` | 时间范围,接受 `YYYY-MM-DDTHH:MM`(datetime-local)或 `YYYY-MM-DD`(按整天) |
返回:
```json
{"ok": true, "file": "core.log",
"files": [{"name": "core.log", "label": "核心(adb/调度) · core.log",
"size": 1175910, "mtime": 1789519242, "rotated": false}],
"rows": [{"ts": "2026-09-16 08:26:50", "level": "INFO",
"module": "core.disc", "msg": "发现: …", "cont": false}],
"matched": 11423, "scanned": 11423, "truncated": true}
```
- `rows` 保持文件原顺序(旧→新),即"最近 N 条命中"按时间正序排列;
- `cont=true` 是**续行**(traceback 的缩进行等):它本身没有时间/级别前缀,
过滤时**继承上一条带前缀的行**,所以按 ERROR 筛选不会把堆栈拆散;
- `matched` 是命中总数,`truncated=true` 表示更早的命中没返回(应缩小时间范围或加关键字);
- `scanned` 是实际扫描行数(上限 50 万行)。
#### 10.2 任务步骤明细(`/api/step_logs*`)
数据源是 `task_step_log` 表(**结构化**,与上面的文本日志不是一回事):每一次步骤
执行一条,能按设备/任务/结果/时间过滤、能按运行归组、能导出 CSV。写入侧见
[DATA_MODEL.md](DATA_MODEL.md) §2.8 与 `core/step_log.py`。
| 接口 | 参数 | 返回 |
|------|------|------|
| `GET /api/step_logs` | `serial` `job_id` `result` `run_id` `q` `since` `until` `limit`(≤2000,默认 200) `offset` | `rows`(**最近的在前**)、`total`、`stats`(本次进程的 queued/written/dropped/failed)、`keep_days`、`max_rows_per_run` |
| `GET /api/step_logs/runs` | `serial` `job_id` `since` `until` `limit`(≤500) | `runs[]`:`run_id` `started_at` `ended_at` `steps` `failures` |
| `GET /api/step_logs/filters` | — | `devices[]` / `jobs[]`(**只列明细里真的出现过的**,不依赖设备池与任务表)、`results[]` |
| `GET /api/step_logs/download` | 同列表接口 | 附件 `.csv`(UTF-8 **带 BOM**),最多 2 万行,按时间正序 |
`result` 取值:`ok` / `miss`(handler 返回 False,如元素没找到)/ `error`(抛异常)/
`unknown`(未知步骤类型)/ `skip`(概率未触发)/ `cap`(本次运行已达记录上限)。
`run_id` 是"设备 × 任务 × 第几次尝试",同一行里能拿到 `step_path`(如 `2.1.3`)
还原嵌套结构——**步骤明细面板的「最近运行概览 → 查看」就是按它过滤**。
> 稳定性:记录走异步队列(`record()` 零阻塞),**队列满会丢弃**(计数在 `stats.dropped`,
> 页面提示栏会显示),所以明细允许缺条 —— 权威结论仍看任务状态与 [NOTIFY.md](NOTIFY.md) 的事件。
--- ---
+10 -3
View File
@@ -30,6 +30,8 @@
│ │
┌───────────────────────────▼──────────────────────────────────────────┐ ┌───────────────────────────▼──────────────────────────────────────────┐
│ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │ │ 基础层 adb_helper · u2_helper · uiauto_helper · ocr · clipboard │
│ notifier(通知分发:队列/聚合/限流/适配器) │
│ step_log(步骤明细:队列 + 批量落库 + 保留期清理) │
│ models(SQLite)· logger · config │ │ models(SQLite)· logger · config │
└──────────────────────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────────────────┘
▲ ▲
@@ -77,15 +79,15 @@
|------|------|---------| |------|------|---------|
| **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` | | **B** | `:28-58` | `Flask(__name__)`;会话密钥(`.env` 的 `WEB_SECRET_KEY`,缺失则随机生成并 warning);`TEMPLATES_AUTO_RELOAD=True`;**数据库目标由 `core/db_config` 装配**(`.env` 的 `DEPLOY_ENV`/`DB_*` → URI + 引擎参数),配置错直接 `SystemExit(2)`;`LoginManager` + `login_view="auth.login"` |
| **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 | | **C** | `:60-67` | **恢复任务消费** `consume_pending_restore()`。SQLite 时代它必须在 engine 首次打开 `users.db` **之前**(Windows 无法替换被持有的文件);改用 MySQL 后这一步的语义会变成"启动期事务替换",见 §7 |
| **D** | `:69-80` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` | | **D** | `:69-88` | `init_db(app)`(建表 → 补列 → 版本账本 → 唯一索引 → 默认管理员 → 旧 JSON 迁移)→ `notifier.init_app`(通知 dispatcher/sender 线程)与 `step_log.init_app`(步骤明细写线程)→ **恢复任务消费** `consume_pending_restore()` → **库环境标签校验 + 启动横幅**(`db_config.verify_deployment_label/print_banner`,不符拒绝启动);`device_pool.init_app`(**起一次性线程**,3s 后采集型号);`device_discovery.init_app`(**起常驻扫描线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)` |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动 ### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
| 阶段 | 位置 | 做了什么 | | 阶段 | 位置 | 做了什么 |
|------|------|---------| |------|------|---------|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) | | **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
| **F** | `:71-80` | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检;失败仅 warning | | **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning |
| **G** | `:253-265`(`__main__`) | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev | | **G** | `__main__` | `_ensure_uiauto_running()`(拉起 uiautodev:20242,写 `data/uiauto.pid`,`atexit` 清理)→ `_preconnect_pool_devices()`(后台并发 connect 池内网络设备)→ `_purge_step_log_async()`(后台清理超期步骤明细)→ `_run_server()`(候选端口依次 bind:`0.0.0.0:18050` → `127.0.0.1:18050` → `127.0.0.1:18051..18055`);退出时 `notifier.shutdown()` + `step_log.shutdown()` + `mgr.shutdown()` + `device_discovery.shutdown()` + 停 uiautodev |
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。 > ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
@@ -109,6 +111,9 @@
| `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 个 |
| **步骤明细写线程** | `core/step_log.init_app` | 批量落库 `task_step_log`(队列满丢弃并计数) | 常驻 1 个 |
两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。 两个 APScheduler 相互独立,时区均固定 `Asia/Shanghai`。
@@ -391,5 +396,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) 配置速查 |
+41 -1
View File
@@ -27,7 +27,7 @@
启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。 启动时校验「`.env` 声明」与「库名」「库中登记的 `app_meta.deployment_env`」三方一致,不符**拒绝启动**。
**表清单(12 张,全部是 `core/models.py` 里的 ORM 模型)** **表清单(14 张,全部是 `core/models.py` 里的 ORM 模型)**
| # | 表 | 用途 | | # | 表 | 用途 |
|---|---|------| |---|---|------|
@@ -44,6 +44,7 @@
| 11 | `experience_audit` | 经验巡检结论 | | 11 | `experience_audit` | 经验巡检结论 |
| 12 | `agent_action` | 动作库(命名动作) | | 12 | `agent_action` | 动作库(命名动作) |
| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) | | 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) |
| 14 | `task_step_log` | 任务步骤明细(每次步骤执行一条,见 §2.8;**唯一有无界增长风险的表**,靠保留期清理) |
> 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE` > 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE`
> (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY` > (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY`
@@ -136,6 +137,41 @@
> ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。 > ⚠️ SQLAlchemy 模型的 `default=` 是 **Python 侧默认值**,SQLite 建表语句里没有 `DEFAULT` 子句;只有原生建表的表才有真正的 SQL DEFAULT。
### 2.8 `task_step_log` — 任务步骤明细
每一次步骤执行一条(`tasks/generic/task.py:_exec_one` 里记录),是「日志 → 步骤明细」
页的数据源。与 `logs/task.log` 的分工:那边是**排障原文**(什么都写、10MB 滚动),
这边是**结构化的一份**——设备/任务/步骤/结果/耗时都是列,能过滤、能统计、能导出 CSV。
| 列 | 类型 | 默认 | 说明 |
|----|------|------|------|
| `id` | Integer | — | 主键 |
| `run_id` | String(24) | `""` | 一次运行 = 设备 × 任务 × 第几次尝试;`TaskManager._run_with_retry` 每次尝试生成一个(12 位 hex),把这次尝试的所有步骤串起来 |
| `job_id` / `job_name` | String(32/120) | `""` | 任务快照(任务删了明细还在,名字仍可读) |
| `serial` / `device_name` | String(120/80) | `""` | 设备地址与**当时**的名字(快照,改名不影响历史) |
| `step_path` | String(32) | `""` | 嵌套位置,如 `2.1.3`;容器步骤(loop/group/if_el)会记自己那条,children 追加一级 |
| `step_label` / `step_type` | String(120/40) | `""` | 步骤标签与类型(`click_el`/`loop`/`wait`…) |
| `selector` | String(300) | `""` | 元素选择器(长选择器截断) |
| `result` | String(16) | `""` | `ok` / `miss`(handler 返回 False)/ `error`(抛异常)/ `unknown`(未知步骤类型)/ `skip`(概率未触发)/ `cap`(本次运行已达上限) |
| `detail` | String(500) | `""` | 异常消息、跳过原因等 |
| `duration_ms` | Integer | `0` | 本步耗时(慢步骤一眼可辨) |
| `created_at` | String(20) | `""` | 执行时刻(**保留期按它算**) |
索引:`run_id`、`created_at`、`(serial, created_at)`、`(job_id, created_at)`。
**两条硬边界**(都在 `core/step_log.py`):
| 常量 | 默认 | 作用 |
|---|---|---|
| `MAX_ROWS_PER_RUN` | 2000 | 单次运行最多记 2000 条,超出只补一条 `cap` 说明行。**没有它,`forever` 循环任务会瞬间写爆这张表** |
| `KEEP_DAYS` | 14 | 保留期:每天 04:13(+ 每次启动)清理更早的记录 |
> ⚠️ **这张表是唯一有无界增长风险的表**,而它会**自动进整库备份**(§6 的派生规则),
> 所以 `KEEP_DAYS` 直接决定备份包体积。调大之前先想清楚导出的 zip 会有多大。
写入走 `core/step_log.py` 的**专用写线程 + 有界队列**(任务线程只 `put_nowait`,
微秒级;队列满丢弃并计数)——步骤执行是热路径,绝不能在任务线程里同步写库。
--- ---
## 3. 非模型表 ## 3. 非模型表
@@ -240,8 +276,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`(方言中立、自动加引号)。
+7 -2
View File
@@ -212,9 +212,14 @@ tail -20 logs/web.log # 无 ERROR/Traceback
SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values())) SUMMARY_TABLES = tuple(sorted(t.name for t in db.metadata.tables.values()))
``` ```
当前 12 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` / 当前 14 张表:`app_meta` / `user` / `device_group` / `task_job` / `custom_action` /
`apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` / `apk_file` / `device` / `pending_device` / `agent_conversation` / `agent_experience` /
`experience_audit` / `agent_action`。完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。 `experience_audit` / `agent_action` / `device_install_log` / `task_step_log`。
完整说明见 [DATA_MODEL.md](DATA_MODEL.md) §6。
> ⚠️ `task_step_log`(任务步骤明细)是**唯一会持续增长**的表:它按 `KEEP_DAYS`
> (默认 14 天,见 `core/step_log.py`)自动清理,但备份包里会带上保留期内的全部行。
> 设备多、任务密时导出 zip 会明显变大——需要更小的包就调小那个常量。
> **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action` > **新增一张 ORM 表,就自动进了覆盖清单**,不可能再漏(2026-09-10 动作库 `agent_action`
> 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。 > 曾因手工维护漏登记,数据其实在快照里,只是清单没列 → 被误判为"没有备份")。
+14 -3
View File
@@ -35,6 +35,16 @@
> 每个改动**单独分支 + 单独 commit**,主题单一,便于评审与回退。不允许"开发完顺手 commit/push"。 > 每个改动**单独分支 + 单独 commit**,主题单一,便于评审与回退。不允许"开发完顺手 commit/push"。
**修复也走这条流程,包括线上紧急修复**(2026-09-14 补:当天两次 APK 安装修复因为"生产卡着"
直接提交在 dev 上、紧接着就合 main 部署,被负责人纠正):
- 紧急修复照样从 dev 切 `fix/xxx` 分支——**不允许直接 commit/push 到 dev**,
哪怕改动只有几行、哪怕生产正卡着
- 第 ② 步的"自测"要**跑通本机服务**(起服务/点页面/看日志),不能只有脚本级验证
- 第 ③ 步的确认要点名两件事:**「是否合 dev」**和**「是否合 main + 部署生产」**——
紧急场景可以一次性问清,但**不能把两个节点合并成一次默认同意**
- 线上正卡住时:先**恢复**(重启/回滚等运维动作,经确认即可执行),再按流程走修复
--- ---
## 2. 技术红线(违反会打断共享 adb transport 或造成生产事故) ## 2. 技术红线(违反会打断共享 adb transport 或造成生产事故)
@@ -46,7 +56,7 @@
| 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 | | 3 | **空闲设备扫描不主动 connect/disconnect** | 避免扰动共享连接 | 前台扫描对空闲设备直接返回"空闲";设备发现用 socket 探测 |
| 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` | | 4 | **adb key 保持历史 key 不变** | 设备信任该 key,换 key 全部 `unauthorized` | 部署沿用 `~/.android/adbkey` |
| 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 | | 5 | **生产(220)默认只读** | 生产事故成本高 | 任何写操作(pull/重启/改文件)都需负责人确认 |
| 6 | **新增持久化表必须登记备份覆盖清单** | 漏登记 = 等于没备份 | `core/system_backup.py` 的 `SUMMARY_TABLES` + `TABLE_LABELS`,详见 [DEPLOY.md](DEPLOY.md) §5.2 | | 6 | **新增持久化表必须进备份覆盖清单** | 漏登记 = 等于没备份 | `SUMMARY_TABLES` 已由模型元数据自动派生(加了 ORM 表就进清单);**手工要做的只有补 `TABLE_LABELS` 中文标签**,详见 [DEPLOY.md](DEPLOY.md) §5.2 |
| 7 | **功能/配置/接口改动必须同步文档** | 文档落后会误导开发与运维 | 见 §6;索引 [doc/README.md](README.md) | | 7 | **功能/配置/接口改动必须同步文档** | 文档落后会误导开发与运维 | 见 §6;索引 [doc/README.md](README.md) |
### 其它开发约束 ### 其它开发约束
@@ -174,9 +184,10 @@ MCP_ALLOW_WRITE=1 MCP_PLATFORM_USER=admin MCP_PLATFORM_PASS=<密码> \
### 5.2 新增数据库字段/表 ### 5.2 新增数据库字段/表
- 模型改 `core/models.py`;新表 `create_all()` 会建 - 模型改 `core/models.py`;新表 `create_all()` 会建(**幂等**:已是模型就会自动建/补列)
- **老库**要在 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL) - **老库**要在 `SCHEMA_MIGRATIONS` 里加迁移(版本号递增 + SQL)
- **新增表**:登记进 `core/system_backup.py` 的 `SUMMARY_TABLES` + `TABLE_LABELS`(**红线**) - **新增表**:`SUMMARY_TABLES` 由模型元数据**自动派生**(=自动进备份覆盖清单),
仍需手工做的是补 `TABLE_LABELS` 的中文标签(**红线**)
- 更新 [DATA_MODEL.md](DATA_MODEL.md) 与 [DEPLOY.md](DEPLOY.md) §5.2 - 更新 [DATA_MODEL.md](DATA_MODEL.md) 与 [DEPLOY.md](DEPLOY.md) §5.2
### 5.3 修改前端 ### 5.3 修改前端
+245
View File
@@ -0,0 +1,245 @@
# 通知 / 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 次未命中——"任务成功但什么都没做"的隐蔽故障) |
| 业务·任务自己发 | `task.notify.custom`(步骤「发通知」)/ `task.patrol.hit`(任务「公共巡检」命中)——**标题正文由任务自己写**,见下 |
| 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 条噪音。
**多设备批次怎么发**(设备一多,逐台发就等于刷屏):
| 事件 | 多设备批次(>1 台) | 单台任务 |
|---|---|---|
| `task.batch.started` | 发 1 条(N 台) | 发 |
| `task.device.success` | **不发**(批次汇总里已有成功台数) | 发 |
| `task.device.failed` / `.offline` | 逐台发(失败是少数,且要知道是哪台) | 发 |
| `task.batch.finished` | **发 1 条汇总** | 发 |
批次汇总长这样(有失败时才会多出一行「失败设备」,列出名字(型号):原因,最多 5 条):
```
### ✅ 任务批次结束:抖音养号
> **任务ID**:b370bbbc
> **总数**:13
> **成功**:13
> **失败**:0
> **停止**:0
> **跳过**:0
> **耗时(秒)**:1347
> **时间**:2026-09-16 09:22:57
```
单设备事件里的设备一律**显示名字**(`cs1`)而不是地址;只有设备没命名时才退回 IP。
`型号` 取自设备池的快照(后台统一采集的那份),比 worker 每次连接时现采的稳。
**任务自己发的通知**(两种,文案都由任务侧提供):
| 事件 | 谁发 | 文案从哪来 |
|---|---|---|
| `task.notify.custom` | 步骤「发通知」 | 步骤参数 `title` / `message` |
| `task.patrol.hit` | 任务「公共巡检」命中 | 巡检项 `title` / `message`(留空则用默认:`巡检命中:<巡检名>` + 检查结果) |
- 两者都支持占位符 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`
(见 [TASK_DEV.md](TASK_DEV.md) §4.5;`{app}`/`{screen}` 要查设备,不用就别写)。
- **给了 `title` 就用它做消息标题**(不再拼"任务通知:" 前缀),`level` 字段可点名级别
(`info`/`success`/`warning`/`error` → 决定 emoji,默认按事件名猜)。
- 谁能收到仍然只看 webhook 的**事件订阅**:想让某个群收任务自定义消息,就在那条
webhook 上勾 `task.notify.custom` / `task.patrol.hit`。
---
## 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(**事件声明 `agg_window=0` 的一律立即发**,如批次结束 / 服务启停 / 备份恢复) |
| L2 限流 | 每 webhook 一个令牌桶 | 18 条/分(企业微信硬限 20,留余量) |
| L3 折叠 | 被限流的事件**不丢弃**,压成一条「被限流折叠 N 条」摘要 | 最多 60s 一条 |
| L4 背压 | 有界队列(event 2000 / send 1000),满了丢弃并计数 | 溢出会告警一次 |
取舍写明白:**失败通知最多延迟一个聚合窗口(默认 30s)**,换来群不被刷屏。
窗口口径(代码在 `core/notifier.py` 的 `notify()`):
- 事件自己写了 `agg_window=0` → **立即发**,不受 webhook 的窗口影响;
- 其余事件 → 用该 webhook 配置的 `agg_window`(它表示"这个群最多等多久合并")。
「样本」行只在**真的合并了多条**(>1)时出现,且按**设备**维度写(`cs1 · 超时`)——
合并多台设备时写任务名每条都一样,等于没写。
> ⚠️ 保存通知配置(`save_config`)会清空**待发聚合**与限流令牌桶。清理 hook 时
> 顺手丢掉的正是还没到窗口的聚合事件——排障时别把它当成"没发"。
---
## 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` 是**重启后**才发(恢复本身就是重启生效的)
+2 -1
View File
@@ -13,11 +13,12 @@
| [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) | | [ARCHITECTURE.md](ARCHITECTURE.md) | **架构详解**:分层、启动装配顺序、线程与并发模型、设备生命周期、任务调度链路、状态机、关键设计决策与扩展点 | 所有开发者(先读这篇) |
| [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 | | [DATA_MODEL.md](DATA_MODEL.md) | **数据模型**:SQLite 表与字段、schema 迁移、非模型表、`app_meta` 配置键、数据目录、备份覆盖清单 | 后端开发、运维 |
| [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 | | [API.md](API.md) | **HTTP 接口全量**:按蓝图分组的路由表、鉴权、请求/响应示例、非 JSON 响应、错误分支 | 前端开发、外部接入 |
| [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、18 种步骤全表、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 | | [TASK_DEV.md](TASK_DEV.md) | **任务与步骤开发**:TaskType/TaskJob 概念、20 种步骤全表、公共巡检、选择器与定位、自定义动作、新增任务类型模板 | 写任务的开发 |
| [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 | | [MCP.md](MCP.md) | **MCP 手机控制手册**:20 个 `de_*` 工具用法、写操作门控、坐标换算、接入示例 | 接入方、数字员工 |
| [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 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 |
+115 -13
View File
@@ -10,8 +10,8 @@
- [1. 核心概念](#1-核心概念) - [1. 核心概念](#1-核心概念)
- [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps) - [2. 通用步骤任务 generic_steps](#2-通用步骤任务-generic_steps)
- [3. 18 种步骤全表](#3-18-种步骤全表) - [3. 20 种步骤全表](#3-20-种步骤全表)
- [4. 容器步骤与公共参数](#4-容器步骤与公共参数) - [4. 容器步骤与公共参数](#4-容器步骤与公共参数)(含 [4.5 公共巡检](#45-公共巡检任务级独立于步骤画布))
- [5. 选择器与元素定位](#5-选择器与元素定位) - [5. 选择器与元素定位](#5-选择器与元素定位)
- [6. 自定义动作](#6-自定义动作) - [6. 自定义动作](#6-自定义动作)
- [7. 步骤编辑器(前端)](#7-步骤编辑器前端) - [7. 步骤编辑器(前端)](#7-步骤编辑器前端)
@@ -108,7 +108,7 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
--- ---
## 3. 18 种步骤全表 ## 3. 20 种步骤全表
> 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。 > 参数与默认值以 `tasks/generic/task.py` 为准;前端 `STEP_LIB`(`static/admin/editor.js`)负责在编辑器里呈现这些字段。
@@ -118,20 +118,22 @@ 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)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | **默认拟人**:弧线轨迹 + 起止点/幅度/时长抖动 + 本设备手速偏好(见 §8.4);`humanize:false` 还原老的「正中直线」;`jitter:0` 时位置/幅度不抖(时长仍随机);**非法 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)、`duration_min`(0.25)、`duration_max`(0.50)、`distance_ratio`(0.6)、`jitter`(0.15)、`humanize`(True) | — | 每次滑动都重新生成轨迹(同 §8.4);每轮滑动后随机停 0.35~0.75s;命中即(可选)点击并返回 True;**只有 `direction="down"` 才是向下滑**,其余按上滑 |
| 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** | | 9 | `click` | 点击元素 | `selector_type`、`selector_value` | `wait_timeout`(2) | — | xpath 用 `wait(timeout)`,其它选择器用 `exists(timeout)`;`wait_timeout=0` 为立即判断。**空选择器只告警** |
| 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** | | 10 | `click_xy` | 点击坐标 | — | `x`(50)、`y`(50) | — | **屏幕百分比**(0-100,会夹取),50/50 = 屏幕中心。无选择器时的兜底,**最脆的方式** |
| 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` | | 11 | `long_click` | 长按元素 | `selector_type`、`selector_value` | `duration`(1.0)、`wait_timeout`(2) | — | 先等元素出现再 `long_click` |
| 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) | | 12 | `wait_el` | 等待元素 | `selector_type`、`selector_value` | `timeout`(10) | — | 等元素出现(条件等待,优于固定 `wait`) |
| 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) | | 13 | `input_text` | 输入文字 | — | `mode`("random")、`texts`("你好\n有趣\n支持")、`fixed_text`("")、`clear_first`(True) | — | `mode="fixed"` 用 `fixed_text`,否则从 `texts` 按行随机选一条;**只负责输入,不负责定位输入框**(要先 click 输入框) |
| 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 | | 14 | `clipboard` | 剪贴板注入 | `text` | `paste`(True) | — | 走 ClipInject 通道(绕开 Android 10+ 后台写剪贴板限制);`paste=true` 时再触发一次粘贴 |
| 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断 | | 15 | `wait` | 等待 | — | `min`(1.0)、`max`(3.0)、`vary_pace`(False) | — | 随机时长;**分片 sleep**(每 ≤0.5s 检查停止/超时),可被抢占打断;勾了 `vary_pace` 再按**本设备节奏**缩放 0.8~1.35 倍(批量跑时设备之间会逐渐错开) |
| 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 | | 16 | `loop` | 循环块 | — | `loop_mode`("rounds")、`max_iterations`(10)、`loop_duration`(600) | `children` | 见 §4.1 |
| 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group | | 17 | `group` | 动作组 | — | — | `children` | 子步骤**按序执行一次**(不循环);自定义动作拖入画布就是展开成 group |
| 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2 | | 18 | `if_el` | 条件判断 | `selector_type`、`selector_value`、`timeout`(3) | `ocr_click`(False) | `then` / `else` | 见 §4.2;条件类型除元素/OCR 外还支持 **屏幕状态**、**前台App** |
| 19 | `notify` | 发通知 | — | `title`("")、`message`("")、`level`("info") | — | 推一条**自定义**通知(事件 `task.notify.custom`):标题正文自己写,支持 `{device} {serial} {job} {time} {app} {screen}`;谁收到取决于 webhook 的事件订阅。两者都空则跳过 |
| 20 | `stop_self` | 停止本设备 | — | `reason`("") | — | 只停**本设备**的任务(其它设备照跑):置 worker 停止位,后续步骤不再执行,任务记成**被停止而不是失败**(不触发重试) |
--- ---
@@ -149,7 +151,11 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
### 4.2 `if_el` 条件判断 ### 4.2 `if_el` 条件判断
- 先做元素判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套) - 先做判断:命中 → 执行 `then` 分支;未命中 → 执行 `else` 分支(分支都可继续嵌套)
- **`selector_type` 还支持两类非元素条件**(只有条件判断有,别的带选择器的步骤没有):
- `screen` —— 屏幕状态,`selector_value` 填 `off` / `on`(也认 `熄屏`/`灭屏`):读 `dumpsys power` 的 `mWakefulness`;
- `foreground` —— 前台是不是某个包名,`selector_value` 填包名(如 `com.ss.android.ugc.aweme`)。
- 这两类**不参与「选择器健康」统计**(它们不是选择器,不该攒出「连续未命中」告警),也不吃 `timeout`(只看当前状态,不等待)
- `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。 - `selector_type="ocr"` 时改用 **OCR**:截屏 → `find_on_screen(img, selector_value)` 子串匹配;命中且 `ocr_click=true` 会点击命中位置。
- OCR 模式下 **`timeout` 不生效**(单次截图即判定) - OCR 模式下 **`timeout` 不生效**(单次截图即判定)
- OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定) - OCR 不可用(未装 `rapidocr_onnxruntime`)时该步返回 `None`(不判定)
@@ -170,6 +176,65 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
--- ---
### 4.5 公共巡检(任务级,独立于步骤画布)
任务运行期间**要全程盯着的条件**(熄屏就点亮、掉到桌面就通知、刷到广告就停),
配在任务编辑器的「公共巡检」块里,**不进步骤画布**。权威实现在 `core/patrol.py`。
| | 步骤(`params.steps`) | 公共巡检(`params.watchers`) |
|---|---|---|
| 配置位置 | 步骤画布,拖出来的 | 任务编辑器单独一块 |
| 执行方式 | 顺序执行 | **穿插**:每执行完一步、以及长等待的每个分片,看一眼"哪个巡检到点了" |
| 适合 | 一段固定流程 | 全程都要盯的守护条件 |
为什么穿插而不是另起线程:不需要并发模型,也**不会和主流程抢屏幕**(否则巡检点屏幕、
主流程同时也在点,动作互相打断)。代价是**精度受步长影响**——某一步卡 30 秒,
巡检最多晚 30 秒。
配置结构(`params.watchers` 数组,每项):
```json
{"name": "熄屏点亮", "enabled": true, "interval": 60,
"check": "screen_off",
"selector_type": "xpath", "selector_value": "",
"action": "screen_on",
"notify": true, "title": "{device} 熄屏了",
"message": "任务 {job} 在 {time} 发现 {screen},已点亮",
"cooldown": 300}
```
| 字段 | 说明 |
|------|------|
| `interval` | 检查间隔(秒,最小 5)。**首次检查在任务开始时立刻做一次**,之后按间隔 |
| `check` | `screen_off` 屏幕熄灭 / `screen_on` 屏幕亮着 / `element_exists` 元素存在 / `element_missing` 元素不存在 / `foreground_is` 前台是该App / `foreground_not` 前台不是该App |
| `selector_type`/`selector_value` | 元素类检查填选择器;`foreground_*` 填**包名**;屏幕类不用填 |
| `action` | `none` 只发通知 / `screen_on` 点亮屏幕 / `screen_off` 熄灭屏幕 / `stop_self` 停止本设备任务 |
| `notify` | 命中是否推通知(事件 `task.patrol.hit`,见 [NOTIFY.md](NOTIFY.md));`false` 就只做动作 |
| `title`/`message` | 通知标题正文,可用 `{device}` `{serial}` `{job}` `{time}` `{app}` `{screen}`。**文案在动作之前渲染**,所以写的是"发现熄屏,已点亮"而不是"发现亮屏" |
| `cooldown` | 命中后多少秒内不再重复动作/通知(默认 300)。**条件持续成立时(一直熄屏)靠它防刷屏** |
两个能直接抄的例子:
```jsonc
// 1) 熄屏就点亮(不吵人):每分钟看一眼,熄了就点亮
{"name":"熄屏点亮","interval":60,"check":"screen_off","action":"screen_on","notify":false}
// 2) 掉出抖音就通知 + 停本设备:前台不是抖音 → 通知 → 停下来等你处理
{"name":"掉出抖音","interval":60,"check":"foreground_not","selector_value":"com.ss.android.ugc.aweme",
"action":"stop_self","notify":true,"title":"{device} 掉出抖音了",
"message":"当前前台 {app},已停止本设备任务"}
```
要点与坑:
- **巡检不是步骤**:不要试图在画布上找它;它也不计入"已执行 N 次操作"的进度。
- 一次检查要查设备:屏幕 `0.3s`、前台 `0.7s`,**元素检查要 dump UI 树(慢设备上可能几秒)**。
所以 `interval` 别设太小(元素检查建议 ≥30s)。
- 巡检命中会往「日志 → 步骤明细」记一行(`step_type=patrol`,只记命中不记"没事发生")。
- 巡检内部异常只记日志、**绝不影响任务主流程**(同 `notifier.notify` 的口径)。
---
## 5. 选择器与元素定位 ## 5. 选择器与元素定位
### 5.1 `selector_type` 支持的值 ### 5.1 `selector_type` 支持的值
@@ -348,9 +413,9 @@ class MyTask(BaseTask):
def get_action_class(cls, action_type): def get_action_class(cls, action_type):
return None return None
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})} merged = {**DEFAULT_PARAMS, **(params or {})}
return MyWorker(serial, params=merged) return MyWorker(serial, params=merged, ctx=ctx)
``` ```
```python ```python
@@ -369,8 +434,45 @@ from .myapp import task # ← 新增
### 8.3 签名约定 ### 8.3 签名约定
- `BaseWorker.__init__(self, serial, params=None, daemon=True)` - `BaseWorker.__init__(self, serial, params=None, daemon=True, ctx=None)`
- `Task.create_worker(self, serial, params)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理) - `Task.create_worker(self, serial, params, ctx=None)` —— **不要带 `stf_client` / `stf` 形参**(STF 已摘除,历史签名已清理)
- `ctx` 是本次运行的上下文(`run_id` / `job_id` / `job_name` / `device_name`),由
`TaskManager._run_with_retry` 传入;**只用于结构化记录**(如步骤明细
`core/step_log.py`),不参与业务逻辑。不关心就原样透传给 `BaseWorker` 即可。
### 8.4 拟人化:滑动与设备节奏(`core/humanize.py`)
自动化最容易被识别的地方不是"慢",是**每次都一模一样**:同一坐标、同一时长、
13 台设备整齐划一地做同一个动作。`core/humanize.py` 从两个层次解决:
| 层次 | 机制 | 谁决定 |
|---|---|---|
| **每次不同** | 起点/终点/幅度/时长抖动,弧线方向随机 | 每次调用现算(`jitter` 控制幅度,0~0.4) |
| **每台设备不同** | 手速(0.82~1.32)、幅度(0.86~1.16)、弧度、常用横坐标(±9%屏宽)、停顿(0.80~1.35) | `crc32(serial)` 播种,**同设备恒定** |
用法(同步步骤里用):
```python
from core import humanize
humanize.swipe(d, "up", serial=self.serial, duration_min=0.25, duration_max=0.4,
distance_ratio=0.6, jitter=0.15, humanize=True)
humanize.pace(self.serial, 5.0, enabled=True) # wait 步骤的"按设备节奏微调"
```
要点与坑:
- **性格稳定、抖动随机**:稳定才像"不同的人",随机才不像"机器"。性格按 **serial**
而不是设备名播种——改名不改风格,换地址会换风格(地址代表"这一台")。
- 内部用**独立的 `random.Random` 实例**,不碰全局 `random`:worker 是多线程的,
全局 RNG 的取值顺序会被别的线程打乱,性格就串味了。
- **屏幕尺寸别用 `d.info`**:部分设备上一次要 **14 秒**(实测),改用
`humanize.screen_size(d, serial)`——它走 `d.window_size()`(同设备 0.6s)并缓存
120 秒。这让"加了弧线反而更快"成为可能。
- **曲线点数固定为 4**(5 个点):`d.swipe_points` 在慢设备上**每多一个点约多 1 秒**
(实测 2 点 1.4s / 6 点 6.0s / 10 点 10.6s),而设备自己会在点之间插值几十步,
4 个点的贝塞尔已经足够弯。**不要为了"更平滑"把点数调大。**
- `swipe_points` 抛异常时自动退回直线 `d.swipe`(老 agent 兜底),日志会留一行 warning。
- 想关掉:步骤参数 `humanize: false`(完全还原老行为);想调轻:`jitter: 0.05`。
--- ---
+77 -14
View File
@@ -91,22 +91,85 @@ async function deleteGroup(name){
} }
// ================== Tab 4: 日志 ================== // ================== Tab 4: 日志 ==================
// 条件(文件/关键字/级别/时间)→ /api/logs(结构化行);下载走 /api/logs/download
function _logParams(){
const v=id=>document.getElementById(id);
const p=new URLSearchParams();
p.set('file', v('log-file').value||'');
p.set('lines', v('log-lines').value);
const q=v('log-q').value.trim(); if(q)p.set('q',q);
const lv=v('log-level').value; if(lv)p.set('level',lv);
const since=v('log-since').value; if(since)p.set('since',since);
const until=v('log-until').value; if(until)p.set('until',until);
return p;
}
// 文件下拉由接口返回的清单渲染(写死的选项会漏掉新增模块,如 notify.log)
function _renderLogFiles(files,current){
const sel=document.getElementById('log-file');
const sig=(files||[]).map(f=>f.name).join(',');
if(sel.dataset.sig!==sig){ // 只在清单变化时重建,避免打断当前选择
sel.dataset.sig=sig;
sel.innerHTML=(files||[]).map(f=>
'<option value="'+esc(f.name)+'">'+esc(f.label)+'</option>').join('');
}
if(current)sel.value=current;
}
async function loadLogs(){ async function loadLogs(){
const file=document.getElementById('log-file').value;
const lines=document.getElementById('log-lines').value;
const r=await apiGet('/api/logs?file='+encodeURIComponent(file)+'&lines='+lines);
if(!r||!r.ok)return;
const content=r.content||'';
// 简单着色
const colored=esc(content)
.replace(/\[ERROR\]/g,'<span class="log-err">[ERROR]</span>')
.replace(/\[WARNING\]/g,'<span class="log-warn">[WARNING]</span>')
.replace(/\[INFO\]/g,'<span class="log-info">[INFO]</span>');
document.getElementById('log-content').innerHTML=colored||'<span class="text-muted">(空)</span>';
document.getElementById('log-hint').textContent=file+' · '+content.split('\n').length+' 行';
// 自动滚动到底部
const el=document.getElementById('log-content'); const el=document.getElementById('log-content');
el.scrollTop=el.scrollHeight; if(!el)return;
// 用户正往上翻看历史时不要被自动刷新拽回底部
const atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<40;
const r=await apiGet('/api/logs?'+_logParams().toString());
if(!r)return;
_renderLogFiles(r.files,r.file);
const hint=document.getElementById('log-hint');
if(!r.ok){
el.innerHTML='<span class="text-muted">'+esc(r.error||'读取失败')+'</span>';
hint.textContent='';
return;
}
const rows=r.rows||[], kw=document.getElementById('log-q').value.trim();
el.innerHTML=rows.length
? rows.map(row=>_logRowHtml(row,kw)).join('')
: '<span class="text-muted">(无匹配行)</span>';
let text=r.file+' · 命中 '+r.matched+' 行'
+(rows.length<r.matched?'(显示最近 '+rows.length+' 行)':'')
+' · 已扫描 '+r.scanned+' 行';
if(r.truncated)text+=' · 更早的命中已省略:缩小时间范围或加关键字';
hint.textContent=text;
if(atBottom)el.scrollTop=el.scrollHeight;
}
function _logRowHtml(r,kw){
const lvl=(r.level||'').toUpperCase();
const cls=lvl==='ERROR'||lvl==='CRITICAL'?'log-err'
:lvl==='WARNING'?'log-warn':'log-info';
// 续行(traceback 缩进行)没有前缀,只缩进显示消息体
const head=r.cont?'':esc(r.ts)+' <span class="'+cls+'">['+esc(lvl)+']</span> ['
+esc(r.module)+'] ';
return '<div class="log-row'+(r.cont?' log-cont':'')+'">'+head+_logHl(r.msg,kw)+'</div>';
}
// 关键字高亮:**先转义再匹配**——反过来的话消息里的 < > 会先被吃成实体
function _logHl(text,kw){
const safe=esc(text||'');
if(!kw)return safe;
const k=esc(kw);
if(!k)return safe;
const re=new RegExp(k.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi');
return safe.replace(re,m=>'<span class="log-hit">'+m+'</span>');
}
function downloadLogs(){
window.location='/api/logs/download?'+_logParams().toString();
}
function resetLogFilters(){
['log-q','log-since','log-until'].forEach(id=>{document.getElementById(id).value='';});
document.getElementById('log-level').value='';
loadLogs();
} }
function toggleLogAuto(){ function toggleLogAuto(){
+10 -2
View File
@@ -106,7 +106,11 @@ function showTab(name){
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);} if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();} if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();} if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);} if(name==='logs'){
showSubTab('logs', _activeSubs.logs||'files'); // 文件日志 / 步骤明细
if((_activeSubs.logs||'files')==='files'
&& document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);
}
if(name==='agent'){ if(name==='agent'){
showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务 showSubTab('agent', _activeSubs.agent||'chat'); // 聊天 / AI 建任务
if(typeof initAgentChat==='function') initAgentChat(); if(typeof initAgentChat==='function') initAgentChat();
@@ -117,7 +121,8 @@ function showTab(name){
// ================== 页内子分栏(任务/工具 通用) ================== // ================== 页内子分栏(任务/工具 通用) ==================
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位 // 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat'}; let _activeSubs = {tasks: 'plan', tools: 'clipboard', system: 'backup', agent: 'chat',
logs: 'files'};
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时) let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
function showSubTab(tabId, name){ function showSubTab(tabId, name){
@@ -126,6 +131,9 @@ 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(tabId==='logs' && name==='files' && typeof loadLogs==='function') loadLogs();
if(tabId==='logs' && name==='steps' && typeof loadStepLogs==='function') loadStepLogs(true);
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'){
+81 -20
View File
@@ -5,8 +5,8 @@ var STEP_LIB=[
{type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}}, {type:'click',label:'点击元素',icon:'✦',cat:'interact',params:{selector_type:'xpath',selector_value:'',wait_timeout:2}},
{type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}}, {type:'click_xy',label:'点击坐标',icon:'🎯',cat:'interact',params:{x:50,y:50}},
{type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}}, {type:'long_click',label:'长按元素',icon:'👆',cat:'interact',params:{selector_type:'xpath',selector_value:'',duration:1.0,wait_timeout:2}},
{type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50}}, {type:'swipe',label:'滑动',icon:'↕',cat:'interact',params:{direction:'up',duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}},
{type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true}}, {type:'swipe_until',label:'滑动直到元素',icon:'🔍',cat:'interact',params:{selector_type:'xpath',selector_value:'',direction:'up',max_swipes:8,click_when_found:true,duration_min:0.25,duration_max:0.50,distance_ratio:0.6,jitter:0.15,humanize:true}},
{type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}}, {type:'input_text',label:'输入文字',icon:'⌨',cat:'interact',params:{mode:'random',texts:'你好\n有趣\n支持',fixed_text:'',clear_first:true}},
{type:'clipboard',label:'剪贴板注入',icon:'📋',cat:'interact',params:{text:'',paste:true}}, {type:'clipboard',label:'剪贴板注入',icon:'📋',cat:'interact',params:{text:'',paste:true}},
{type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}}, {type:'key_event',label:'按键',icon:'⌨️',cat:'interact',params:{key:'back'}},
@@ -19,7 +19,9 @@ var STEP_LIB=[
{type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}}, {type:'wait_el',label:'等待元素',icon:'⏳',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:10}},
{type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}}, {type:'loop',label:'循环块',icon:'↻',cat:'flow',params:{loop_mode:'rounds',max_iterations:10,loop_duration:600,children:[]}},
{type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}}, {type:'group',label:'动作组',icon:'📦',cat:'flow',params:{children:[]}},
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}} {type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,then:[],else:[]}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}}
]; ];
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']]; var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作 var _customActions=[]; // 后端加载的自定义动作
@@ -46,19 +48,31 @@ function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;})
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用 // 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持) // withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
function _selRowHtml(p, extra, withOcr){ function _selRowHtml(p, extra, withOcr){
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr']: // withOcr(条件判断用)额外提供"屏幕状态 / 前台App"两类非元素条件
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr','screen','foreground']:
['xpath','description','text','resourceId','descriptionContains','className']; ['xpath','description','text','resourceId','descriptionContains','className'];
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId', var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别'}; descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是'};
var opts=types.map(function(d){ var opts=types.map(function(d){
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join(''); return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
var toggle=withOcr?' onchange="_stepEditor._toggleOcrMode(this)"':''; var toggle=withOcr?' onchange="_stepEditor._condTypeChanged(this)"':'';
var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':''; var pick=_can('devices')?'<button class="btn btn-xs btn-pick" onclick="_stepEditor.openPicker(this)">抓取元素</button>':'';
var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':''; var test=_can('devices')?'<button class="btn btn-xs" onclick="_stepEditor._testStep(this)" title="在设备上试执行本步骤,验证选择器是否命中">测试</button>':'';
var isScreen=p.selector_type==='screen', isFg=p.selector_type==='foreground';
var valLabel=isScreen?'屏幕状态':(isFg?'前台包名':'选择器值');
var valHtml;
if(isScreen){
valHtml='<select class="form-control" data-key="selector_value" style="max-width:140px">'+
'<option value="off"'+(p.selector_value!=='on'?' selected':'')+'>熄屏时就命中</option>'+
'<option value="on"'+(p.selector_value==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{
valHtml='<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+
'" placeholder="'+(isFg?'如 com.ss.android.ugc.aweme':'如 点赞')+'">'+(isFg?'':pick+test);
}
return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+opts+'</select></div>'+ return '<div class="form-row"><div class="form-group"><label>选择器类型</label><select class="form-control" data-key="selector_type"'+toggle+'>'+opts+'</select></div>'+
'<div class="form-group" style="grid-column:span 2"><label>选择器值</label><div class="sel-picker">'+ '<div class="form-group" style="grid-column:span 2"><label>'+valLabel+'</label><div class="sel-picker">'+
'<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+'" placeholder="如 点赞">'+pick+test+ valHtml+'</div></div></div>'+(extra||'');
'</div></div></div>'+(extra||'');
} }
// 加载自定义动作库 // 加载自定义动作库
@@ -69,6 +83,22 @@ function _loadCustomActions(){
}).catch(function(){}); }).catch(function(){});
} }
// 「拟人」开关 + 抖动幅度(滑动类步骤共用;见 core/humanize.py)
function _humanizeChkHtml(p){
return '<div class="form-row"><div class="form-group form-check">'
+'<input type="checkbox" data-key="humanize" '+(p.humanize!==false?'checked':'')+'>'
+'<label>拟人轨迹(弧线)</label></div>'
+'<div class="form-group"><label>抖动幅度</label><input type="number" class="form-control" data-key="jitter" value="'
+(p.jitter!=null?p.jitter:0.15)+'" step="0.05" min="0" max="0.4"></div></div>';
}
function _humanizeRowHtml(p,extra){
return extra+_humanizeChkHtml(p)
+'<div class="help">拟人:滑动走弧线,起止点/幅度/时长每次都抖一点,并按本设备的手速与幅度偏好执行'
+'(每台设备风格稳定不同,批量跑时不像同步机器人)。关掉=还原成正中直线;'
+'抖动填 0=位置/幅度不抖(时长仍在你填的范围内随机)</div>';
}
var _stepEditor={ var _stepEditor={
_steps:[], _steps:[],
_selected:[], _selected:[],
@@ -291,7 +321,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'){
@@ -299,6 +329,7 @@ var _stepEditor={
['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>'; ['up','down','left','right'].map(function(d){return '<option value="'+d+'"'+(p.direction===d?' selected':'')+'>'+d+'</option>';}).join('')+'</select></div>';
h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>'; h+='<div class="form-group"><label>最短时长(秒)</label><input type="number" class="form-control" data-key="duration_min" value="'+(p.duration_min||0.25)+'" step="0.05" min="0.05"></div>';
h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>'; h+='<div class="form-group"><label>最长时长(秒)</label><input type="number" class="form-control" data-key="duration_max" value="'+(p.duration_max||0.50)+'" step="0.05" min="0.05"></div></div>';
h+=_humanizeRowHtml(p,'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div>');
}else if(step.type==='click'){ }else if(step.type==='click'){
h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>'); h+=_selRowHtml(p,'<div class="form-group"><label>等待超时(秒)</label><input type="number" class="form-control" data-key="wait_timeout" value="'+(p.wait_timeout||2)+'" step="0.5" min="0.5"></div>');
}else if(step.type==='long_click'){ }else if(step.type==='long_click'){
@@ -308,9 +339,11 @@ var _stepEditor={
h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+ h+=_selRowHtml(p,'<div class="form-row"><div class="form-group"><label>方向</label><select class="form-control" data-key="direction">'+
'<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+ '<option value="up"'+(p.direction!=='down'?' selected':'')+'>上滑</option>'+
'<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+ '<option value="down"'+(p.direction==='down'?' selected':'')+'>下滑</option></select></div>'+
'<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div></div>'+ '<div class="form-group"><label>最多滑动次数</label><input type="number" class="form-control" data-key="max_swipes" value="'+(p.max_swipes||8)+'" min="1" max="50"></div>'+
'<div class="form-group"><label>幅度(屏占比)</label><input type="number" class="form-control" data-key="distance_ratio" value="'+(p.distance_ratio!=null?p.distance_ratio:0.6)+'" step="0.05" min="0.1" max="0.9"></div></div>'+
'<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+ '<div class="form-group form-check"><input type="checkbox" data-key="click_when_found" '+(p.click_when_found!==false?'checked':'')+'><label>找到后点击</label></div>'+
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续</div>'); _humanizeChkHtml(p)+
'<div class="help">上滑/下滑直到目标元素出现(每次滑动后检查一次),找不到则跳过继续。滑动同样走拟人轨迹</div>');
}else if(step.type==='wait_el'){ }else if(step.type==='wait_el'){
h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+ h+=_selRowHtml(p,'<div class="form-group"><label>最长等待(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||10)+'" min="1" max="120"></div>'+
'<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>'); '<div class="help">等待目标元素出现(替代固定秒数等待),超时则继续</div>');
@@ -324,9 +357,25 @@ var _stepEditor={
return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>'; return '<option value="'+k[0]+'"'+(p.key===k[0]?' selected':'')+'>'+k[1]+'</option>';
}).join('')+'</select></div>'; }).join('')+'</select></div>';
h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>'; h+='<div class="help">返回键用于退出评论/返回上一页,比结束App轻量</div>';
}else if(step.type==='notify'){
h+='<div class="form-group"><label>标题</label><input type="text" class="form-control" data-key="title" value="'+esc(p.title||'')+'" placeholder="如 抖音号1 掉线了(留空用任务通知)"></div>';
h+='<div class="form-group"><label>内容</label><textarea class="form-control" data-key="message" rows="2" style="min-height:52px">'+esc(p.message||'')+'</textarea></div>';
h+='<div class="form-row"><div class="form-group"><label>级别</label><select class="form-control" data-key="level">'+
[['info','ℹ️ 普通'],['success','✅ 正常'],['warning','⚠️ 警告'],['error','❌ 异常']].map(function(l){
return '<option value="'+l[0]+'"'+((p.level||'info')===l[0]?' selected':'')+'>'+l[1]+'</option>';}).join('')+
'</select></div></div>';
h+='<div class="help">推送到哪条群取决于 webhook 自己的<b>事件订阅</b>(勾了「任务自定义通知」的才收)。'
+'正文可用占位符:<b>{device}</b> 设备名、<b>{serial}</b> 地址、<b>{job}</b> 任务名、<b>{time}</b> 时间、'
+'<b>{app}</b> 当前前台包名、<b>{screen}</b> 亮屏/熄屏(后两个要查设备,不用就别写)</div>';
}else if(step.type==='stop_self'){
h+='<div class="form-group"><label>停止原因(可留空)</label><input type="text" class="form-control" data-key="reason" value="'+esc(p.reason||'')+'" placeholder="如 连续刷到广告"></div>';
h+='<div class="help">只停<b>本设备</b>的任务,其它设备照常;后续步骤不再执行,任务记为「被停止」而不是失败'
+'(不会触发失败重试)。常配合「条件判断」用:命中条件 → 先「发通知」再「停止本设备」</div>';
}else if(step.type==='wait'){ }else if(step.type==='wait'){
h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>'; h+='<div class="form-row"><div class="form-group"><label>最短(秒)</label><input type="number" class="form-control" data-key="min" value="'+(p.min||1.0)+'" step="0.5" min="0.1"></div>';
h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>'; h+='<div class="form-group"><label>最长(秒)</label><input type="number" class="form-control" data-key="max" value="'+(p.max||3.0)+'" step="0.5" min="0.1"></div></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="vary_pace" '+(p.vary_pace?'checked':'')+'><label>按设备节奏微调(0.8~1.35 倍)</label></div>';
h+='<div class="help">勾上后每台设备按自己的节奏缩放这个等待:批量跑时设备之间会逐渐错开,不再整齐划一。代价是实际时长不再严格等于上面填的值</div>';
}else if(step.type==='clipboard'){ }else if(step.type==='clipboard'){
h+='<div class="form-group"><label>注入内容</label><textarea class="form-control" data-key="text" rows="3" style="font-family:monospace;min-height:70px">'+esc(p.text||'')+'</textarea></div>'; h+='<div class="form-group"><label>注入内容</label><textarea class="form-control" data-key="text" rows="3" style="font-family:monospace;min-height:70px">'+esc(p.text||'')+'</textarea></div>';
h+='<div class="form-group form-check"><input type="checkbox" data-key="paste" '+(p.paste!==false?'checked':'')+'><label>注入后立即粘贴(需输入框已聚焦)</label></div>'; h+='<div class="form-group form-check"><input type="checkbox" data-key="paste" '+(p.paste!==false?'checked':'')+'><label>注入后立即粘贴(需输入框已聚焦)</label></div>';
@@ -353,12 +402,17 @@ var _stepEditor={
h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>'; h+='<div class="help">动作组:按顺序执行下方子步骤一次。把多个步骤打包成一个可复用的整体。</div>';
h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>'; h+='<div class="form-group"><button class="btn btn-xs btn-primary" onclick="_stepEditor.saveAsCustomAction(\''+esc(step.id||'')+'\')">存为自定义动作</button> <span class="help" style="display:inline">把本组步骤保存到左侧自定义动作库,方便复用</span></div>';
}else if(step.type==='if_el'){ }else if(step.type==='if_el'){
// 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground');
h+=_selRowHtml(p, h+=_selRowHtml(p,
'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+ (dyn?'':'<div class="form-group"><label>查找超时(秒)</label><input type="number" class="form-control" data-key="timeout" value="'+(p.timeout||3)+'" min="0.5" max="30" step="0.5"></div>'+
'<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+ '<div class="form-group form-check" data-ocr-field style="display:'+(p.selector_type==='ocr'?'block':'none')+'">'+
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+ '<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>')+
'<div class="help">超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'+ '<div class="help">'+(dyn
'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)</div>', true); ? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
+'<b>OCR识别</b>可匹配图片/画布里的文字(截屏识别,跨平台)')+'</div>', true);
} }
// 共享:触发概率(所有步骤通用,0~100) // 共享:触发概率(所有步骤通用,0~100)
h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>'; h+='<div class="form-row" style="margin-top:8px"><div class="form-group"><label>触发概率(%)</label><input type="number" class="form-control" data-key="probability" value="'+(p.probability!=null?p.probability:100)+'" min="0" max="100"><div class="help">0~100,&lt;100 表示按概率执行(如 30=隔几次才触发一次,适合"偶尔点赞")</div></div></div>';
@@ -626,10 +680,12 @@ var _stepEditor={
}); });
}, },
// 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关 // 条件判断:选择器类型切到 OCR 时显示"命中后点击"开关
_toggleOcrMode:function(sel){ _condTypeChanged:function(sel){
var card=sel.closest('.step-card'); // 条件类型一变,"值"控件也要跟着换(元素值输入框 / 屏幕亮熄下拉 / 包名输入),
var f=card&&card.querySelector('[data-ocr-field]'); // 整卡重渲染最省心。先把 DOM 的值同步回 _steps,否则重渲染会丢掉刚改的东西。
if(f)f.style.display=sel.value==='ocr'?'block':'none'; this._syncFromDom();
var self=this;
setTimeout(function(){self._renderCanvas();},0);
}, },
// 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组 // 给数组分配唯一 id,用于 move/duplicate/remove/拖拽定位到正确的父数组
_arrId:function(arr){ _arrId:function(arr){
@@ -1220,6 +1276,11 @@ async function saveTask(jobId){
} }
const mdEl=document.getElementById('f-max_duration'); const mdEl=document.getElementById('f-max_duration');
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps}; params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
// 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py)
if(typeof patrolCollect==='function'){
const ws=patrolCollect();
if(ws.length)params.watchers=ws;
}
}else{ }else{
try{params=JSON.parse(document.getElementById('f-params').value||'{}');} try{params=JSON.parse(document.getElementById('f-params').value||'{}');}
catch(e){showToast('参数 JSON 格式错误','error');return;} catch(e){showToast('参数 JSON 格式错误','error');return;}
+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');
});
}
+143
View File
@@ -0,0 +1,143 @@
// ================== 「日志 → 步骤明细」子分栏 ==================
// 数据源:task_step_log 表(结构化),接口 /api/step_logs*(见 web/admin_api.py)。
// 与「文件日志」的分工:那边是原始文本,这边能按设备/任务/时间/结果过滤、能导出。
let _slOffset = 0; // 分页偏移
let _slTotal = 0; // 命中总数
let _slRunId = ''; // "只看某次运行"时的 run_id(空 = 不限)
let _slInited = false; // 下拉选项只拉一次
function _slVal(id){const el=document.getElementById(id);return el?el.value:'';}
function _slParams(){
const p=new URLSearchParams();
const put=(k,v)=>{if(v)p.set(k,v);};
put('serial', _slVal('sl-device'));
put('job_id', _slVal('sl-job'));
put('result', _slVal('sl-result'));
put('q', _slVal('sl-q').trim());
put('since', _slVal('sl-since'));
put('until', _slVal('sl-until'));
put('run_id', _slRunId);
p.set('limit', _slVal('sl-limit')||200);
p.set('offset', _slOffset);
return p;
}
async function loadStepLogs(reset){
const body=document.getElementById('sl-body');
if(!body)return;
if(reset)_slOffset=0;
const p=_slParams();
const r=await apiGet('/api/step_logs?'+p.toString());
if(!r||!r.ok){body.innerHTML='<tr><td colspan="9" class="text-muted">读取失败</td></tr>';return;}
_slTotal=r.total||0;
const rows=r.rows||[];
body.innerHTML = rows.length ? rows.map(_slRowHtml).join('')
: '<tr><td colspan="9" class="text-muted">(没有匹配的步骤记录——先跑一个任务,'
+'或在上面放宽筛选条件)</td></tr>';
// 提示栏:分页位置 + 保留期 + 运行期间丢弃计数(排障用)
const from=_slTotal?(_slOffset+1):0, to=_slOffset+rows.length;
let hint=`命中 ${_slTotal} 条 · 显示 ${from}-${to}`;
if(_slRunId)hint=`只看运行 ${_slRunId} · `+hint;
const st=r.stats||{};
if(st.dropped)hint+=` · 队列溢出丢弃 ${st.dropped} 条`;
if(st.failed)hint+=` · 落库失败 ${st.failed} 条`;
document.getElementById('sl-hint').textContent=hint;
document.getElementById('sl-page').textContent=
`第 ${Math.floor(_slOffset/(parseInt(_slVal('sl-limit'),10)||200))+1} 页`;
document.getElementById('sl-prev').disabled = _slOffset<=0;
document.getElementById('sl-next').disabled = _slOffset+rows.length>=_slTotal;
if(!_slInited){_slInited=true;loadStepFilters();}
if(reset)loadStepRuns();
}
// 结果 → 颜色。注意不能复用 .log-err/.log-warn/.log-info:那套的作用域是
// .log-content(文件日志面板),表格里得用下面这套 res-*
const _SL_RESULT={error:'res-error',unknown:'res-error',miss:'res-miss',
cap:'res-miss',skip:'res-skip',ok:'res-ok'};
function _slRowHtml(r){
const cls=_SL_RESULT[r.result]||'';
const dev=esc(r.device_name||r.serial||'');
const dur=r.duration_ms>=1000?(r.duration_ms/1000).toFixed(1)+'s':(r.duration_ms||0)+'ms';
return '<tr>'
+'<td class="text-muted" style="white-space:nowrap">'+esc(r.created_at||'')+'</td>'
+'<td title="'+esc(r.serial||'')+'">'+dev+'</td>'
+'<td>'+esc(r.job_name||'')+'</td>'
+'<td>'+esc(r.step_path||'')+'</td>'
+'<td>'+esc(r.step_label||'')+'</td>'
+'<td class="text-muted">'+esc(r.step_type||'')+'</td>'
+'<td class="'+cls+'">'+esc(r.result||'')+'</td>'
+'<td class="text-muted">'+dur+'</td>'
+'<td class="text-muted" title="'+esc(r.detail||'')+'">'+esc(_slTrim(r.detail,120))+'</td>'
+'</tr>';
}
function _slTrim(s,n){s=s||'';return s.length>n?s.slice(0,n)+'…':s;}
function stepLogPage(delta){
const size=parseInt(_slVal('sl-limit'),10)||200;
_slOffset=Math.max(0,_slOffset+delta*size);
loadStepLogs(false);
}
function resetStepFilters(){
['sl-q','sl-since','sl-until'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
['sl-device','sl-job','sl-result'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';});
_slRunId='';
loadStepLogs(true);
}
function downloadStepLogs(){
const p=_slParams();
p.delete('limit');p.delete('offset');
window.location='/api/step_logs/download?'+p.toString();
}
// ---------- 筛选下拉(只列"明细里真的出现过"的设备/任务) ----------
async function loadStepFilters(){
const r=await apiGet('/api/step_logs/filters');
if(!r||!r.ok)return;
const dev=document.getElementById('sl-device');
const job=document.getElementById('sl-job');
const keep=(sel,val)=>{const cur=sel.value;sel.innerHTML=val;sel.value=cur;};
keep(dev,'<option value="">全部设备</option>'+(r.devices||[]).map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.device_name||d.serial)+'</option>').join(''));
keep(job,'<option value="">全部任务</option>'+(r.jobs||[]).map(j=>
'<option value="'+esc(j.job_id)+'">'+esc(j.job_name||j.job_id)+'</option>').join(''));
document.getElementById('sl-note').textContent=
`明细保留 ${r.keep_days} 天(超期自动清理);单次运行最多记录 `
+`${r.max_rows_per_run} 条(无限循环任务不会写爆这张表)。`;
}
// ---------- 最近运行概览(点一行 = 只看那次运行) ----------
async function loadStepRuns(){
const p=new URLSearchParams();
if(_slVal('sl-device'))p.set('serial',_slVal('sl-device'));
if(_slVal('sl-job'))p.set('job_id',_slVal('sl-job'));
if(_slVal('sl-since'))p.set('since',_slVal('sl-since'));
if(_slVal('sl-until'))p.set('until',_slVal('sl-until'));
p.set('limit','20');
const r=await apiGet('/api/step_logs/runs?'+p.toString());
const body=document.getElementById('sl-runs-body');
if(!r||!r.ok){body.innerHTML='';return;}
const runs=r.runs||[];
document.getElementById('sl-runs-summary').textContent=
`最近运行概览(${runs.length} 次,点「查看」只看那次)`;
body.innerHTML = runs.length ? runs.map(x=>'<tr>'
+'<td class="text-muted" style="white-space:nowrap">'+esc(x.started_at||'')+'</td>'
+'<td>'+esc(x.device_name||x.serial||'')+'</td>'
+'<td>'+esc(x.job_name||'')+'</td>'
+'<td>'+x.steps+'</td>'
+'<td class="'+(x.failures?'res-error':'text-muted')+'">'+(x.failures||0)+'</td>'
+'<td><button class="btn btn-sm" onclick="filterByRun(\''+esc(x.run_id)+'\')">查看</button></td>'
+'</tr>').join('')
: '<tr><td colspan="6" class="text-muted">(暂无运行记录)</td></tr>';
}
function filterByRun(runId){
_slRunId=runId||'';
document.getElementById('sl-runs-box').open=false;
loadStepLogs(true);
}
+135
View File
@@ -80,6 +80,128 @@ function _normalizeTaskEnv(env){
}; };
} }
// ================== 公共巡检(任务级,独立于步骤画布) ==================
// 与后端 core/patrol.py 的 CHECKS/ACTIONS 一一对应;选项变动时两边一起改。
// 存进任务 params.watchers,worker 在步骤之间穿插执行(见 core/patrol.py 模块注释)。
const _PATROL_CHECKS=[
['screen_off','屏幕熄灭',''], ['screen_on','屏幕亮着',''],
['element_exists','元素存在','selector'], ['element_missing','元素不存在','selector'],
['foreground_is','前台是该App','package'], ['foreground_not','前台不是该App','package'],
];
const _PATROL_ACTIONS=[
['none','只发通知,不动设备'], ['screen_on','点亮屏幕'],
['screen_off','熄灭屏幕'], ['stop_self','停止本设备任务'],
];
let _patrolItems=[];
function patrolInit(items){
_patrolItems=(items||[]).filter(function(x){return x&&typeof x==='object';})
.map(function(x){return Object.assign({},x);});
patrolRender();
}
function patrolAdd(){
patrolSync();
_patrolItems.push({enabled:true,name:'',interval:60,check:'screen_off',selector_type:'xpath',
selector_value:'',action:'screen_on',notify:true,
title:'',message:'',cooldown:300});
patrolRender();
}
function patrolRemove(i){
patrolSync();
_patrolItems.splice(i,1);
patrolRender();
}
// DOM → 内存(重渲染前必须调,否则刚填的值会被覆盖)
function patrolSync(){
const list=document.getElementById('patrol-list');
if(!list)return;
list.querySelectorAll('.patrol-card').forEach(function(card,i){
const w=_patrolItems[i]; if(!w)return;
const get=function(k){
const el=card.querySelector('[data-pk="'+k+'"]');
if(!el)return undefined;
return el.type==='checkbox'?el.checked:el.value;
};
['name','check','action','selector_type','selector_value','title','message'].forEach(function(k){
const v=get(k); if(v!==undefined)w[k]=v;
});
['interval','cooldown'].forEach(function(k){
const v=get(k); if(v!==undefined)w[k]=parseInt(v,10)||0;
});
['enabled','notify'].forEach(function(k){
const v=get(k); if(v!==undefined)w[k]=v;
});
});
}
function patrolCollect(){
patrolSync();
return _patrolItems.map(function(w){
return {enabled:w.enabled!==false, name:(w.name||'').trim(), interval:parseInt(w.interval,10)||60,
check:w.check||'screen_off', selector_type:w.selector_type||'xpath',
selector_value:(w.selector_value||'').trim(), action:w.action||'none',
notify:w.notify!==false, title:(w.title||'').trim(), message:(w.message||'').trim(),
cooldown:parseInt(w.cooldown,10)||0};
});
}
// 检查项一变,条件行(选择器/包名)跟着换
function patrolCheckChanged(sel){
const card=sel.closest('.patrol-card');
const need=(_PATROL_CHECKS.find(function(c){return c[0]===sel.value;})||[])[2]||'';
const row=card.querySelector('.patrol-cond');
row.style.display=need?'flex':'none';
const typeBox=row.querySelector('.patrol-seltype');
if(typeBox)typeBox.style.display=(need==='selector')?'block':'none';
const lb=row.querySelector('.patrol-vallabel');
if(lb)lb.textContent=(need==='package')?'前台包名':'选择器值';
const inp=row.querySelector('[data-pk="selector_value"]');
if(inp)inp.placeholder=(need==='package')?'如 com.ss.android.ugc.aweme':'如 点赞';
}
function patrolRender(){
const list=document.getElementById('patrol-list');
if(!list)return;
if(!_patrolItems.length){
list.innerHTML='<div class="text-muted" style="font-size:12px;padding:4px 0">(没有巡检项)</div>';
return;
}
list.innerHTML=_patrolItems.map(_patrolCardHtml).join('');
}
function _patrolCardHtml(w,i){
const need=(_PATROL_CHECKS.find(function(c){return c[0]===w.check;})||[])[2]||'';
const chkOpts=_PATROL_CHECKS.map(function(c){
return '<option value="'+c[0]+'"'+(w.check===c[0]?' selected':'')+'>'+c[1]+'</option>';}).join('');
const actOpts=_PATROL_ACTIONS.map(function(a){
return '<option value="'+a[0]+'"'+(w.action===a[0]?' selected':'')+'>'+a[1]+'</option>';}).join('');
const typeOpts=['xpath','description','text','resourceId','descriptionContains','className']
.map(function(t){return '<option value="'+t+'"'+(w.selector_type===t?' selected':'')+'>'+t+'</option>';}).join('');
return '<div class="patrol-card">'
+'<div class="patrol-row">'
+'<label class="form-check"><input type="checkbox" data-pk="enabled" '+(w.enabled!==false?'checked':'')+'><span>启用</span></label>'
+'<input class="form-control" data-pk="name" style="flex:1" placeholder="巡检名,如 熄屏点亮" value="'+esc(w.name||'')+'">'
+'<button class="btn btn-xs btn-danger" onclick="patrolRemove('+i+')">删除</button>'
+'</div>'
+'<div class="patrol-row">'
+'<div class="pg"><label>每隔(秒)</label><input type="number" class="form-control" data-pk="interval" value="'+(w.interval||60)+'" min="5"></div>'
+'<div class="pg"><label>检查</label><select class="form-control" data-pk="check" onchange="patrolCheckChanged(this)">'+chkOpts+'</select></div>'
+'<div class="pg"><label>命中后动作</label><select class="form-control" data-pk="action">'+actOpts+'</select></div>'
+'<div class="pg"><label>冷却(秒)</label><input type="number" class="form-control" data-pk="cooldown" value="'+(w.cooldown!=null?w.cooldown:300)+'" min="0"></div>'
+'</div>'
+'<div class="patrol-row patrol-cond" style="display:'+(need?'flex':'none')+'">'
+'<div class="pg patrol-seltype" style="display:'+(need==='selector'?'block':'none')+'"><label>选择器类型</label>'
+'<select class="form-control" data-pk="selector_type">'+typeOpts+'</select></div>'
+'<div class="pg" style="flex:1"><label class="patrol-vallabel">'+(need==='package'?'前台包名':'选择器值')+'</label>'
+'<input class="form-control" data-pk="selector_value" value="'+esc(w.selector_value||'')+'" placeholder="'
+(need==='package'?'如 com.ss.android.ugc.aweme':'如 点赞')+'"></div>'
+'</div>'
+'<div class="patrol-row">'
+'<label class="form-check"><input type="checkbox" data-pk="notify" '+(w.notify!==false?'checked':'')+'><span>命中发通知</span></label>'
+'<div class="pg" style="flex:1"><label>标题</label><input class="form-control" data-pk="title" value="'+esc(w.title||'')+'" placeholder="{device} 熄屏了"></div>'
+'<div class="pg" style="flex:2"><label>内容</label><input class="form-control" data-pk="message" value="'+esc(w.message||'')+'" placeholder="任务 {job} 在 {time} 发现 {screen},已处理"></div>'
+'</div>'
+'<div class="help">占位符:{device} 设备名、{serial} 地址、{job} 任务名、{time} 时间、{app} 当前前台包名、'
+'{screen} 亮屏/熄屏(后两个要查设备,不用就别写)。冷却期内重复命中不会重复动作/通知,防刷屏</div>'
+'</div>';
}
// openTaskModal(jobId, prefill) // openTaskModal(jobId, prefill)
// jobId —— 传 id = 编辑已有任务 // jobId —— 传 id = 编辑已有任务
// prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对) // prefill —— AI 建任务的草稿:{task, notes, warnings}(服务端已校验,这里只预填给人核对)
@@ -179,6 +301,16 @@ function openTaskModal(jobId, prefill){
'<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+ '<div class="se-lib" id="se-lib"><div class="lib-title">操作库</div></div>'+
'<div class="se-canvas" id="se-canvas"></div>'+ '<div class="se-canvas" id="se-canvas"></div>'+
'</div>'+ '</div>'+
'</div>'+
// 公共巡检:任务级配置,**不在步骤画布里**(穿插执行,见 core/patrol.py)
'<div class="patrol-box" id="patrol-box">'+
'<div class="patrol-head">'+
'<span>公共巡检<span class="hint" style="margin-left:8px">可选:任务运行期间按间隔穿插检查,命中就动作 + 通知</span></span>'+
'<button class="btn btn-xs" onclick="patrolAdd()">+ 添加巡检</button>'+
'</div>'+
'<div id="patrol-list"></div>'+
'<div class="help">和步骤的区别:步骤是"按顺序做完这一段",巡检是"全程都要盯着的条件"(比如熄屏就点亮、掉到桌面就通知)。'+
'检查穿插在步骤之间执行,不额外抢设备;某一步耗时很长时,巡检最多晚那一步的时间。</div>'+
'</div>'; '</div>';
// AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看 // AI 草稿的提醒/复核项:贴在步骤编辑器上方,别让人漏看
@@ -198,6 +330,7 @@ function openTaskModal(jobId, prefill){
// 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。 // 又保留自动存草稿(保存成功时 saveTask 会 clearDraft 清掉它)。
const draftKey=jobId||(pf?('ai:'+Date.now()):''); const draftKey=jobId||(pf?('ai:'+Date.now()):'');
_stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey); _stepEditor.init(j.params&&j.params.steps?j.params.steps:[],j.params&&j.params.max_duration?j.params.max_duration:0,draftKey);
patrolInit(j.params&&j.params.watchers);
document.getElementById('modal-footer').innerHTML= document.getElementById('modal-footer').innerHTML=
'<button class="btn" onclick="closeModal()">取消</button>'+ '<button class="btn" onclick="closeModal()">取消</button>'+
@@ -285,6 +418,8 @@ function onTaskTypeChange(){
const isGeneric=tt==='generic_steps'; const isGeneric=tt==='generic_steps';
document.getElementById('step-editor').classList.toggle('show',isGeneric); document.getElementById('step-editor').classList.toggle('show',isGeneric);
document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block'; document.getElementById('f-params_wrap').style.display=isGeneric?'none':'block';
const pb=document.getElementById('patrol-box');
if(pb)pb.style.display=isGeneric?'block':'none'; // 巡检只对通用步骤任务有意义
} }
// ================== 自定义动作管理 ================== // ================== 自定义动作管理 ==================
+8 -4
View File
@@ -49,8 +49,8 @@
from .actions import get_action_class from .actions import get_action_class
return get_action_class(action_type) return get_action_class(action_type)
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
return MyWorker(serial, params=params) return MyWorker(serial, params=params, ctx=ctx)
5. 在 my_task/__init__.py 加:from . import task (触发注册) 5. 在 my_task/__init__.py 加:from . import task (触发注册)
6. 在 tasks/__init__.py 加:from .my_task import task (触发注册) 6. 在 tasks/__init__.py 加:from .my_task import task (触发注册)
@@ -85,8 +85,12 @@ class BaseTask:
description = "" description = ""
default_params = {} default_params = {}
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
"""返回一个 threading.Thread(已启动或待启动),执行实际任务。""" """返回一个 threading.Thread(已启动或待启动),执行实际任务。
ctx:本次运行的上下文(`run_id`/`job_id`/`job_name`/`device_name`),
由 TaskManager 传入,供步骤明细等结构化记录标注来源;可为 None。
"""
raise NotImplementedError raise NotImplementedError
@classmethod @classmethod
+299 -41
View File
@@ -26,6 +26,8 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
wait_el - 等待元素出现(条件等待,替代固定时长 wait) wait_el - 等待元素出现(条件等待,替代固定时长 wait)
input_text - 输入文字(随机候选/指定文字,可先清空) input_text - 输入文字(随机候选/指定文字,可先清空)
wait - 等待时长 wait - 等待时长
notify - 发自定义通知(标题/正文自己写,推送 webhook)
stop_self - 停止本设备的任务(其它设备不受影响)
loop - 循环块(含 children 步骤列表 + max_iterations) loop - 循环块(含 children 步骤列表 + max_iterations)
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用) group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
""" """
@@ -35,8 +37,10 @@ import time
from tasks.base import BaseTask, register_task 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,
screen_is_on, current_package)
from core.logger import get_logger from core.logger import get_logger
from core import notifier, step_log, humanize, patrol
_log = get_logger("task.generic") _log = get_logger("task.generic")
@@ -45,6 +49,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]`(只改整体前缀,保留后续子路径)。
@@ -74,10 +85,13 @@ STEP_TYPES = [
{"type": "key_event", "label": "按键", "icon": "⌨️", {"type": "key_event", "label": "按键", "icon": "⌨️",
"params": {"key": "back"}}, "params": {"key": "back"}},
{"type": "swipe", "label": "滑动", "icon": "↕", {"type": "swipe", "label": "滑动", "icon": "↕",
"params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50}}, "params": {"direction": "up", "duration_min": 0.25, "duration_max": 0.50,
"distance_ratio": 0.6, "jitter": 0.15, "humanize": True}},
{"type": "swipe_until", "label": "滑动直到元素", "icon": "🔍", {"type": "swipe_until", "label": "滑动直到元素", "icon": "🔍",
"params": {"selector_type": "xpath", "selector_value": "", "direction": "up", "params": {"selector_type": "xpath", "selector_value": "", "direction": "up",
"max_swipes": 8, "click_when_found": True}}, "max_swipes": 8, "click_when_found": True,
"duration_min": 0.25, "duration_max": 0.50,
"distance_ratio": 0.6, "jitter": 0.15, "humanize": True}},
{"type": "click", "label": "点击元素", "icon": "✦", {"type": "click", "label": "点击元素", "icon": "✦",
"params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}}, "params": {"selector_type": "description", "selector_value": "", "wait_timeout": 2}},
{"type": "click_xy", "label": "点击坐标", "icon": "🎯", {"type": "click_xy", "label": "点击坐标", "icon": "🎯",
@@ -99,6 +113,10 @@ STEP_TYPES = [
{"type": "if_el", "label": "条件判断", "icon": "❓", {"type": "if_el", "label": "条件判断", "icon": "❓",
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3, "params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
"then": [], "else": []}}, "then": [], "else": []}},
{"type": "notify", "label": "发通知", "icon": "🔔",
"params": {"title": "", "message": "", "level": "info"}},
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
"params": {"reason": ""}},
] ]
@@ -116,13 +134,29 @@ class GenericStepsWorker(BaseWorker):
只实现 run_task,按步骤类型分发到 _exec_<type> 方法。 只实现 run_task,按步骤类型分发到 _exec_<type> 方法。
""" """
def __init__(self, serial, params=None): def __init__(self, serial, params=None, ctx=None):
super().__init__(serial, params) super().__init__(serial, params, ctx=ctx)
p = {**DEFAULT_PARAMS, **(self.params or {})} p = {**DEFAULT_PARAMS, **(self.params or {})}
self.max_duration = int(p.get("max_duration", 0)) self.max_duration = int(p.get("max_duration", 0))
self.steps = p.get("steps", []) self.steps = p.get("steps", [])
self._action_counts = {} # 步骤执行计数 {step_label: count} self._action_counts = {} # 步骤执行计数 {step_label: count}
self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数 self._miss_counts = {} # 选择器健康:selector -> 连续未命中次数
# 步骤路径:容器步骤(loop/group/if_el)进出时压栈,得到 "2.1.3" 这种位置,
# 写进「步骤明细」用来还原嵌套结构。worker 每台设备一个线程,实例级状态安全。
self._path = []
self._step_rows = 0 # 本次运行已记录的明细条数(封顶见 _record_step)
self._step_capped = False
# 公共巡检(任务级配置,独立于步骤画布;见 core/patrol.py):
# _wid 内部标识,用来记"下次到点 / 上次命中"两个节奏
self._watchers = []
for i, w in enumerate(p.get("watchers") or []):
if not isinstance(w, dict) or not w.get("enabled", True):
continue
w = dict(w)
w["_wid"] = f"w{i}"
self._watchers.append(w)
self._watch_next = {} # wid -> 下次到点(缺省 0 = 任务一开始就先查一次)
self._watch_last = {} # wid -> 上次命中(冷却用)
# 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版) # 某 click 选择器连续未找到元素的次数达到该值,判定可能失效(App 改版)
_MAX_CONSECUTIVE_MISS = 10 _MAX_CONSECUTIVE_MISS = 10
@@ -141,6 +175,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。"""
@@ -165,10 +203,17 @@ class GenericStepsWorker(BaseWorker):
if depth > 5: if depth > 5:
_log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过") _log.warning(f"[{self.serial}] 步骤嵌套深度超限(>5),跳过")
return return
for step in steps: for i, step in enumerate(steps):
if self.stopped() or self.is_time_up(): if self.stopped() or self.is_time_up():
return return
self._exec_one(d, step, depth) self._maybe_patrol(d) # 穿插:每步之前看一眼有没有巡检到点了
# 压入本步在兄弟里的序号(1 起):容器步骤执行 children 时会在其后
# 继续追加,得到 "2.1" 这样的嵌套路径
self._path.append(str(i + 1))
try:
self._exec_one(d, step, depth)
finally:
self._path.pop()
def _exec_one(self, d, step, depth=0): def _exec_one(self, d, step, depth=0):
"""执行单个步骤。""" """执行单个步骤。"""
@@ -185,19 +230,29 @@ class GenericStepsWorker(BaseWorker):
prob = float(params.get("probability", 100)) prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob: if prob < 100 and random.random() * 100 > prob:
_log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过") _log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过")
self._record_step(step, "skip", f"概率 {prob}% 未触发", 0)
return return
self.set_action(f"执行: {label}") self.set_action(f"执行: {label}")
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}") _log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
ret = None ret = None
# 结果归类:异常 > 未命中(handler 返回 False)> 正常
# (True/None 都算正常——None 是"没有判断语义"的步骤,如点击坐标)
result, detail = "ok", ""
t0 = time.time()
handler = getattr(self, f"_exec_{stype}", None) handler = getattr(self, f"_exec_{stype}", None)
if handler: if handler:
try: try:
ret = handler(d, params, depth) ret = handler(d, params, depth)
if ret is False:
result, detail = "miss", "未命中/超时"
except Exception as e: except Exception as e:
_log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}") _log.error(f"[{self.serial}] 步骤 {label}({stype}) 异常: {e}")
result, detail = "error", str(e)
else: else:
_log.warning(f"[{self.serial}] 未知步骤类型: {stype}") _log.warning(f"[{self.serial}] 未知步骤类型: {stype}")
result, detail = "unknown", f"未知步骤类型: {stype}"
self._record_step(step, result, detail, int((time.time() - t0) * 1000))
# 容器步骤(loop/group/if_el)不计入操作计数,避免监控显示噪音 # 容器步骤(loop/group/if_el)不计入操作计数,避免监控显示噪音
if stype not in ("loop", "group", "if_el"): if stype not in ("loop", "group", "if_el"):
self._action_counts[label] = self._action_counts.get(label, 0) + 1 self._action_counts[label] = self._action_counts.get(label, 0) + 1
@@ -208,6 +263,109 @@ class GenericStepsWorker(BaseWorker):
action_counts=dict(self._action_counts), elapsed=elapsed) action_counts=dict(self._action_counts), elapsed=elapsed)
return ret # 命中结果(True/False/None),"测试此步骤"用 return ret # 命中结果(True/False/None),"测试此步骤"用
def _record_step(self, step, result, detail, duration_ms):
"""把这一步写进「步骤明细」(异步落库,不阻塞任务线程)。
单次运行条数封顶:`forever` 循环任务会执行成千上万步,不封顶这张表
会被瞬间写爆。到顶后只补一条 cap 说明行,此后本次运行不再记录。
"""
try:
# 只记"任务运行"里的步骤:编辑器里的「测试此步骤」也会走 _exec_one,
# 但没有 run_id(不归任何一次运行),记进来只是噪音
if not self.ctx.get("run_id") or self._step_capped:
return
params = step.get("params") or {}
label = step.get("label") or step.get("type", "")
if self._step_rows >= step_log.MAX_ROWS_PER_RUN:
self._step_capped = True
step_log.record(
run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_path=".".join(self._path), step_label=label,
step_type=step.get("type", ""), result="cap",
detail=f"本次运行明细已达上限 {step_log.MAX_ROWS_PER_RUN} 条,"
f"后续步骤不再记录(保留期与上限见 core/step_log.py)")
return
self._step_rows += 1
step_log.record(
run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_path=".".join(self._path), step_label=label,
step_type=step.get("type", ""),
selector=str(params.get("selector_value", "") or ""),
result=result, detail=detail, duration_ms=duration_ms)
except Exception:
pass # 记录失败绝不能影响任务执行
# ================== 公共巡检(任务级,穿插执行) ==================
def _maybe_patrol(self, d):
"""看一眼有没有巡检到点了;到点就跑一次检查(配置见 core/patrol.py)。
穿插式:不起线程、不抢屏幕,所以巡检精度受主流程步长影响——某一步卡
30 秒,巡检最多晚 30 秒(这是刻意取舍,换来的是绝不会和主流程打架)。
"""
if not self._watchers or self.stopped():
return
now = time.time()
for w in self._watchers:
wid = w["_wid"]
if now < self._watch_next.get(wid, 0):
continue
# 先排下一次再执行:检查本身可能慢(元素检查要 dump UI 树),
# 否则慢检查会导致"每分钟"变成"每检查一次"
self._watch_next[wid] = now + max(5, int(w.get("interval") or 60))
try:
self._run_patrol(d, w, now)
except Exception as e:
_log.warning(f"[{self.serial}] 巡检[{w.get('name') or wid}]异常: {e}")
def _run_patrol(self, d, w, now):
"""执行一次巡检:命中 → 过冷却 → 做动作 + 发通知 + 记一条明细。"""
wid, name = w["_wid"], (w.get("name") or patrol.check_label(w.get("check")))
hit, detail = patrol.evaluate(d, w)
if not hit:
return
# 命中后冷却:条件持续成立(比如一直熄屏)时不要每分钟都动作/发通知
cooldown = max(0, int(w.get("cooldown") or 0))
if now - self._watch_last.get(wid, 0) < cooldown:
_log.info(f"[{self.serial}] 巡检[{name}]命中({detail})但在冷却期内,不重复动作")
return
self._watch_last[wid] = now
# 文案在**动作之前**渲染:动作会改变设备状态(点亮后 {screen} 就成了"亮屏"),
# 而用户要看的是"发现时"的样子——"发现熄屏,已点亮"而不是"发现亮屏,已点亮"
title = self._render_vars(d, w.get("title") or "") or f"巡检命中:{name}"
message = self._render_vars(d, w.get("message") or "") or detail
done = patrol.act(d, w, self)
_log.info(f"[{self.serial}] 巡检[{name}]命中:{detail}"
f"{(' → ' + done) if done else '(只通知)'}")
self.set_action(f"巡检[{name}]:{detail}{(',' + done) if done else ''}")
summary = f"{detail}{(' → ' + done) if done else ''}"
try: # 明细:只记命中,不记"没事发生"
step_log.record(run_id=self.ctx.get("run_id", ""), serial=self.serial,
device_name=self.ctx.get("device_name", ""),
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
step_label=f"巡检[{name}]", step_type="patrol",
result="hit", detail=summary)
except Exception:
pass
if w.get("notify", True):
notifier.notify("task.patrol.hit",
patrol_name=name,
check_label=patrol.check_label(w.get("check")),
action_label=patrol.action_label(w.get("action")),
detail=detail,
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
serial=self.serial,
device_name=self.ctx.get("device_name", ""),
title=title, message=message,
level=w.get("level", "warning"))
# ================== 步骤执行器 ================== # ================== 步骤执行器 ==================
def _exec_screen_on(self, d, params, depth=0): def _exec_screen_on(self, d, params, depth=0):
"""亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。""" """亮屏:息屏时唤醒并滑动解锁。任务执行前用(息屏时 u2 无法操作)。"""
@@ -228,17 +386,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}")
@@ -290,13 +466,14 @@ class GenericStepsWorker(BaseWorker):
if not sel_val: if not sel_val:
_log.warning("swipe_until 缺少选择器") _log.warning("swipe_until 缺少选择器")
return None return None
info = d.info # 每次滑动都重新生成(弧线方向/幅度/时长都不一样),不用预算好的固定坐标
w, h = info["displayWidth"], info["displayHeight"] swipe_kw = dict(
cx = int(w * 0.5) serial=self.serial,
if direction == "down": duration_min=float(params.get("duration_min", 0.25)),
sy, ey = int(h * 0.2), int(h * 0.8) duration_max=float(params.get("duration_max", 0.50)),
else: distance_ratio=float(params.get("distance_ratio", 0.6) or 0.6),
sy, ey = int(h * 0.8), int(h * 0.2) jitter=params.get("jitter", 0.15),
humanize=params.get("humanize", True) is not False)
for i in range(1, max_swipes + 1): for i in range(1, max_swipes + 1):
if self.stopped() or self.is_time_up(): if self.stopped() or self.is_time_up():
return None return None
@@ -322,8 +499,8 @@ class GenericStepsWorker(BaseWorker):
_log.info(f"[{self.serial}] 滑动{i}次后找到: {sel_val}") _log.info(f"[{self.serial}] 滑动{i}次后找到: {sel_val}")
self._track_selector_health(sel_val, True) self._track_selector_health(sel_val, True)
return True return True
d.swipe(cx, sy, cx, ey, 0.3) humanize.swipe(d, direction, **swipe_kw)
time.sleep(0.5) time.sleep(random.uniform(0.35, 0.75)) # 每次滑动后停顿也抖动一下
self._track_selector_health(sel_val, False) self._track_selector_health(sel_val, False)
_log.warning(f"[{self.serial}] 滑动{max_swipes}次未找到元素: {sel_val}") _log.warning(f"[{self.serial}] 滑动{max_swipes}次未找到元素: {sel_val}")
return False return False
@@ -401,7 +578,23 @@ class GenericStepsWorker(BaseWorker):
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过") _log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
return None return None
found = False found = False
if sel_type == "ocr": # 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器,
# 连续未命中不该被记成"选择器失效"
dynamic = sel_type in ("screen", "foreground")
detail = ""
if sel_type == "screen":
# 屏幕亮/熄:selector_value 填 on / off(也认 熄屏/灭屏 这类中文)
want = str(sel_val).strip().lower()
want_off = want in ("off", "0", "false", "no", "熄屏", "灭屏", "黑屏")
state = screen_is_on(d)
found = (state is False) if want_off else (state is True)
detail = (f"屏幕{'熄屏' if state is False else '亮屏' if state else '状态未知'}"
f"(判断{'熄屏' if want_off else '亮屏'})")
elif sel_type == "foreground":
cur = current_package(d)
found = cur == str(sel_val).strip()
detail = f"前台='{cur or '未知'}'(期望 {sel_val})"
elif sel_type == "ocr":
# OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到) # OCR 模式:截图 → 识别文字 → 关键词匹配(UI 树里没有的文字也能找到)
try: try:
from core.ocr import available as _ocr_available, find_on_screen from core.ocr import available as _ocr_available, find_on_screen
@@ -434,29 +627,88 @@ class GenericStepsWorker(BaseWorker):
except Exception as e: except Exception as e:
_log.warning(f"[{self.serial}] 条件判断异常: {e}") _log.warning(f"[{self.serial}] 条件判断异常: {e}")
found = False found = False
self._track_selector_health(sel_val, found) if dynamic:
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
else:
self._track_selector_health(sel_val, found)
branch = params.get("then" if found else "else") or [] branch = params.get("then" if found else "else") or []
self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)") self.set_action(f"条件{'命中' if found else '未命中'} → 执行{'找到' if found else '未找到'}分支({len(branch)}步)")
_log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步") _log.info(f"[{self.serial}] 条件判断 {'命中' if found else '未命中'}: {detail or sel_val},执行{'then' if found else 'else'}分支 {len(branch)} 步")
self._exec_steps(d, branch, depth + 1) self._exec_steps(d, branch, depth + 1)
return found return found
def _exec_swipe(self, d, params, depth=0): def _exec_swipe(self, d, params, depth=0):
"""滑动一次(默认拟人:弧线 + 抖动 + 本设备手速,见 core/humanize.py)。
老行为(正中直线、每次都一样)可以用 humanize=false 还原;
jitter=0 则保留轨迹但去掉随机抖动(对照排障用)。
"""
direction = params.get("direction", "up") direction = params.get("direction", "up")
dur_min = float(params.get("duration_min", 0.25)) info = humanize.swipe(
dur_max = float(params.get("duration_max", 0.50)) d, direction, serial=self.serial,
dur = random.uniform(dur_min, dur_max) duration_min=float(params.get("duration_min", 0.25)),
info = d.info duration_max=float(params.get("duration_max", 0.50)),
w, h = info["displayWidth"], info["displayHeight"] distance_ratio=float(params.get("distance_ratio", 0.6) or 0.6),
cx = int(w * 0.5) jitter=params.get("jitter", 0.15),
if direction == "up": humanize=params.get("humanize", True) is not False)
d.swipe(cx, int(h * 0.8), cx, int(h * 0.2), dur) _log.info(f"[{self.serial}] 滑动 {direction} 时长{info['duration']}s "
elif direction == "down": f"({info['from']}→{info['to']}"
d.swipe(cx, int(h * 0.2), cx, int(h * 0.8), dur) f"{',弧线' if info.get('human') else ',直线'})")
elif direction == "left":
d.swipe(int(w * 0.8), int(h * 0.5), int(w * 0.2), int(h * 0.5), dur) def _exec_notify(self, d, params, depth=0):
elif direction == "right": """发一条自定义通知(标题/正文由任务自己写,支持占位符)。
d.swipe(int(w * 0.2), int(h * 0.5), int(w * 0.8), int(h * 0.5), dur)
事件是 `task.notify.custom`:**谁能收到取决于 webhook 的事件订阅**——
这个步骤只负责"把消息交出去",不关心发给谁(与平台其它通知同一套口径)。
"""
title = self._render_vars(d, params.get("title") or "")
msg = self._render_vars(d, params.get("message") or "")
if not (title or msg):
_log.warning(f"[{self.serial}] notify 步骤既没标题也没内容,跳过")
return None
notifier.notify("task.notify.custom",
job_id=self.ctx.get("job_id", ""),
job_name=self.ctx.get("job_name", ""),
serial=self.serial,
device_name=self.ctx.get("device_name", ""),
title=title or "任务通知", message=msg,
level=params.get("level", "info"))
_log.info(f"[{self.serial}] 已发自定义通知: {title} {msg}")
return True
def _render_vars(self, d, text):
"""替换通知文本里的占位符:{device} {serial} {job} {time} {app} {screen}。
`{app}` / `{screen}` 要查设备(0.3~0.7s),**只在文本里真用到时才查**,
否则每条通知都白付一次设备往返。
"""
if not text or "{" not in text:
return text
vals = {"device": self.ctx.get("device_name") or self.serial,
"serial": self.serial,
"job": self.ctx.get("job_name", ""),
"time": time.strftime("%Y-%m-%d %H:%M:%S")}
if "{app}" in text:
vals["app"] = current_package(d) or "未知"
if "{screen}" in text:
st = screen_is_on(d)
vals["screen"] = "未知" if st is None else ("亮屏" if st else "熄屏")
for k, v in vals.items():
text = text.replace("{" + k + "}", str(v))
return text
def _exec_stop_self(self, d, params, depth=0):
"""停止本设备的任务(其它设备的任务不受影响)。
实现只是置 worker 的停止位:`_exec_steps` 每步前检查 `stopped()`,所以
后续步骤不会再跑;TaskManager 侧把它记成"被停止"而不是失败
(见 `core/task_manager.py` 的 `_run_with_retry` 自停分支)。
"""
reason = (params.get("reason") or "").strip()
_log.info(f"[{self.serial}] 任务内主动停止本设备({reason or '未填原因'})")
self.set_action(f"停止本设备任务{(':' + reason) if reason else ''}")
self.stop()
return True
def _exec_click(self, d, params, depth=0): def _exec_click(self, d, params, depth=0):
sel_type = params.get("selector_type", "xpath") sel_type = params.get("selector_type", "xpath")
@@ -531,11 +783,17 @@ class GenericStepsWorker(BaseWorker):
max_s = float(params.get("max", 3.0)) max_s = float(params.get("max", 3.0))
# 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断) # 分片 sleep:每 0.5s 检查停止/超时信号,抢占任务能及时接管(纯 sleep 无法中断)
total = random.uniform(min_s, max_s) total = random.uniform(min_s, max_s)
# 可选:按本设备节奏缩放(勾了"设备节奏差异"才生效,默认关——
# 时长被放大/缩小是业务语义,不能悄悄改)
total = humanize.pace(self.serial, total, enabled=params.get("vary_pace", False))
end = time.time() + total end = time.time() + total
while time.time() < end: while time.time() < end:
if self.stopped() or self.is_time_up(): if self.stopped() or self.is_time_up():
return return
time.sleep(min(0.5, end - time.time())) self._maybe_patrol(d) # 长等待里也看一眼,别让巡检被 wait 拖住
# max(0, …):巡检本身要花时间(屏幕 0.3s、前台 0.7s、元素检查更久),
# 扣掉这段时间后可能已经过点了,负数 sleep 会直接抛异常
time.sleep(max(0.0, min(0.5, end - time.time())))
def _exec_loop(self, d, params, depth=0): def _exec_loop(self, d, params, depth=0):
children = params.get("children", []) children = params.get("children", [])
@@ -641,9 +899,9 @@ class GenericStepsTask(BaseTask):
def get_action_class(cls, action_type): def get_action_class(cls, action_type):
return None return None
def create_worker(self, serial, params): def create_worker(self, serial, params, ctx=None):
merged = {**DEFAULT_PARAMS, **(params or {})} merged = {**DEFAULT_PARAMS, **(params or {})}
return GenericStepsWorker(serial, params=merged) return GenericStepsWorker(serial, params=merged, ctx=ctx)
def test_step(serial, step): def test_step(serial, step):
+157 -7
View File
@@ -112,7 +112,31 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
.log-content .log-err{color:#f87171} .log-content .log-err{color:#f87171}
.log-content .log-warn{color:#fbbf24} .log-content .log-warn{color:#fbbf24}
.log-content .log-info{color:#67e8f9} .log-content .log-info{color:#67e8f9}
.log-toolbar{display:flex;gap:10px;margin-bottom:12px;align-items:center} .log-row{white-space:pre-wrap}
.log-cont{color:#8a94a6;padding-left:2.2em} /* 续行(traceback 缩进行):淡色缩进 */
.log-hit{background:#fbbf2433} /* 关键字命中高亮 */
/* 「公共巡检」配置块(任务编辑器里,独立于步骤画布) */
.patrol-box{margin-top:14px;border:1px solid var(--border-strong);border-radius:10px;padding:12px}
.patrol-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.patrol-head>span:first-child{font-weight:600;font-size:13px}
.patrol-card{border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;background:var(--surface-2,#fafafa)}
.patrol-row{display:flex;gap:10px;align-items:flex-end;margin-bottom:8px;flex-wrap:wrap}
.patrol-row:last-child{margin-bottom:0}
.patrol-row .pg{display:flex;flex-direction:column;gap:2px;min-width:110px}
.patrol-row .pg label{font-size:11px;color:var(--text-light)}
.patrol-row .form-check{margin-bottom:6px;display:flex;align-items:center;gap:4px;font-size:12px}
.patrol-cond{background:rgba(59,130,246,.05);border-radius:6px;padding:6px 8px;margin:0 -2px 8px}
/* 「步骤明细」表格的结果列配色(.log-* 那套作用域在 .log-content 里,表格用这套) */
.res-error{color:#dc2626;font-weight:600} /* 异常 / 未知步骤类型 */
.res-miss{color:#d97706} /* 未命中 / 超时 / 已达上限 */
.res-skip{color:var(--text-light)} /* 概率未触发 */
.res-ok{color:#059669}
/* 工具栏:控件较多,允许换行;控件本身不参与收缩(否则会被挤成竖排文字) */
.log-toolbar{display:flex;flex-wrap:wrap;gap:8px 10px;margin-bottom:12px;align-items:center}
.log-toolbar>*{flex:0 0 auto}
.log-toolbar .hint{flex:1 1 200px;min-width:160px}
/* 区块标题 */ /* 区块标题 */
.section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px} .section-title{font-family:var(--display);font-size:15px;font-weight:700;color:var(--text);margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--primary);display:inline-block;letter-spacing:.2px}
@@ -778,25 +802,103 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<!-- ========== Tab 4: 日志 ========== --> <!-- ========== Tab 4: 日志 ========== -->
<div id="tab-logs" class="tab-panel"> <div id="tab-logs" class="tab-panel">
<div class="page-title">日志查看</div> <div class="page-title">日志查看</div>
<div class="page-sub">查看系统各模块运行日志</div> <div class="page-sub">原始文本日志 + 任务步骤明细(结构化,可按设备/任务/时间过滤)</div>
<div class="sub-tabs">
<button class="sub-tab active" data-sub="files" onclick="showSubTab('logs','files')">文件日志</button>
<button class="sub-tab" data-sub="steps" onclick="showSubTab('logs','steps')">步骤明细</button>
</div>
<!-- 子分栏 1:文件日志 -->
<div class="sub-panel active" id="logs-sub-files">
<div class="log-toolbar"> <div class="log-toolbar">
<select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"> <!-- 文件下拉由 /api/logs 返回的清单动态渲染(含 .1/.2 滚动历史与 notify.log) -->
<option value="core.log">核心日志 (core.log)</option> <select id="log-file" class="form-control" style="width:auto" onchange="loadLogs()"></select>
<option value="task.log">任务日志 (task.log)</option> <input id="log-q" class="form-control" style="width:190px" placeholder="关键字(设备号/包名/报错…)"
<option value="web.log">Web日志 (web.log)</option> onkeydown="if(event.key==='Enter')loadLogs()">
<option value="action.log">操作日志 (action.log)</option> <select id="log-level" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="">全部级别</option>
<option value="ERROR">仅 ERROR</option>
<option value="WARNING">WARNING 及以上</option>
<option value="INFO">INFO 及以上</option>
</select> </select>
<input id="log-since" type="datetime-local" class="form-control" style="width:auto"
title="起始时间" onchange="loadLogs()">
<span class="text-muted">~</span>
<input id="log-until" type="datetime-local" class="form-control" style="width:auto"
title="结束时间" onchange="loadLogs()">
<select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()"> <select id="log-lines" class="form-control" style="width:auto" onchange="loadLogs()">
<option value="100">最近 100 行</option> <option value="100">最近 100 行</option>
<option value="300" selected>最近 300 行</option> <option value="300" selected>最近 300 行</option>
<option value="500">最近 500 行</option> <option value="500">最近 500 行</option>
<option value="1000">最近 1000 行</option> <option value="1000">最近 1000 行</option>
<option value="5000">最近 5000 行</option>
</select> </select>
<label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label> <label class="form-check"><input type="checkbox" id="log-auto" checked onchange="toggleLogAuto()"> 自动刷新</label>
<button class="btn" onclick="loadLogs()">刷新</button> <button class="btn" onclick="loadLogs()">刷新</button>
<button class="btn" onclick="downloadLogs()" title="下载当前筛选结果(无筛选即整个文件)">下载</button>
<button class="btn" onclick="resetLogFilters()">重置</button>
<span class="hint" id="log-hint"></span> <span class="hint" id="log-hint"></span>
</div> </div>
<div id="log-content" class="log-content"></div> <div id="log-content" class="log-content"></div>
</div><!-- /logs-sub-files -->
<!-- 子分栏 2:步骤明细(task_step_log 表,结构化,可按设备/任务/时间/结果过滤) -->
<div class="sub-panel" id="logs-sub-steps">
<div class="log-toolbar">
<select id="sl-device" class="form-control" style="width:auto" onchange="loadStepLogs(true)"></select>
<select id="sl-job" class="form-control" style="width:auto" onchange="loadStepLogs(true)"></select>
<select id="sl-result" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<option value="">全部结果</option>
<option value="error">异常 error</option>
<option value="miss">未命中 miss</option>
<option value="unknown">未知类型 unknown</option>
<option value="skip">概率跳过 skip</option>
<option value="ok">正常 ok</option>
<option value="cap">已截断 cap</option>
</select>
<input id="sl-q" class="form-control" style="width:170px" placeholder="关键字(步骤名/选择器/详情)"
onkeydown="if(event.key==='Enter')loadStepLogs(true)">
<input id="sl-since" type="datetime-local" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<span class="text-muted">~</span>
<input id="sl-until" type="datetime-local" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<select id="sl-limit" class="form-control" style="width:auto" onchange="loadStepLogs(true)">
<option value="100">每页 100</option>
<option value="200" selected>每页 200</option>
<option value="500">每页 500</option>
</select>
<button class="btn" onclick="loadStepLogs(true)">刷新</button>
<button class="btn" onclick="downloadStepLogs()" title="导出当前筛选结果(CSV)">导出 CSV</button>
<button class="btn" onclick="resetStepFilters()">重置</button>
<span class="hint" id="sl-hint"></span>
</div>
<div class="hint" id="sl-note"></div>
<details id="sl-runs-box" style="margin:10px 0">
<summary style="cursor:pointer;font-size:13px" id="sl-runs-summary">最近运行概览</summary>
<table class="table" style="margin-top:8px">
<thead><tr>
<th style="width:150px">开始</th><th style="width:110px">设备</th><th>任务</th>
<th style="width:70px">步骤数</th><th style="width:80px">失败</th><th style="width:70px"></th>
</tr></thead>
<tbody id="sl-runs-body"></tbody>
</table>
</details>
<table class="table">
<thead><tr>
<th style="width:145px">时间</th><th style="width:105px">设备</th><th style="width:150px">任务</th>
<th style="width:60px">步骤</th><th>标签</th><th style="width:95px">类型</th>
<th style="width:75px">结果</th><th style="width:65px">耗时</th><th>详情</th>
</tr></thead>
<tbody id="sl-body"></tbody>
</table>
<div class="toolbar" style="margin-top:10px">
<button class="btn" id="sl-prev" onclick="stepLogPage(-1)">上一页</button>
<span class="hint" id="sl-page"></span>
<button class="btn" id="sl-next" onclick="stepLogPage(1)">下一页</button>
</div>
</div><!-- /logs-sub-steps -->
</div> </div>
<!-- ========== Tab 5: 用户 ========== --> <!-- ========== Tab 5: 用户 ========== -->
@@ -1112,6 +1214,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 +1254,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 +1367,7 @@ 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>
<script src="/static/admin/steplog.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)
+168 -15
View File
@@ -1,17 +1,19 @@
"""管理域 API:用户管理/日志查看。""" """管理域 API:用户管理/日志查看。"""
import os
import time import time
from io import BytesIO
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request, send_file
from flask_login import current_user from flask_login import current_user
from flask import session from flask import session
from core.logger import _LOG_DIR, _MODULE_FILES from core import logger
from core.logger import get_logger
from core.models import db, User from core.models import db, User
from web import context from web import context
from web.auth import (admin_required, perm_required, _validate_perms, from web.auth import (admin_required, perm_required, _validate_perms,
PERM_LOGS) PERM_LOGS)
_log = get_logger("web")
bp = Blueprint("admin", __name__) bp = Blueprint("admin", __name__)
@bp.route("/api/users") @bp.route("/api/users")
@@ -79,22 +81,173 @@ def api_users_delete(uid):
# ================== API:日志查看 ================== # ================== API:日志查看 ==================
#
# 过滤/白名单都在 core.logger 里做(读取侧与写入侧共用同一份文件清单,
# 见 core/logger.py「日志读取」一节);这里只负责取参数 + 组装响应。
def _log_filters():
"""从 query string 取过滤条件(/api/logs 与 /api/logs/download 共用)。"""
return dict(keyword=request.args.get("q", ""),
min_level=request.args.get("level", ""),
since=request.args.get("since", ""),
until=request.args.get("until", ""))
@bp.route("/api/logs") @bp.route("/api/logs")
@perm_required(PERM_LOGS) @perm_required(PERM_LOGS)
def api_logs(): def api_logs():
files = list(_MODULE_FILES.values()) """读日志:可按关键字 / 最低级别 / 时间范围过滤,返回结构化行。
current = request.args.get("file", "core.log")
lines = int(request.args.get("lines", 300)) 参数:file 文件名(白名单)、q 关键字、level 最低级别(ERROR 含以上…)、
content = "" since/until 时间、lines 返回条数(取**最近** N 条命中)。
path = os.path.join(_LOG_DIR, current) """
if os.path.exists(path): files = logger.list_log_files()
try: names = [f["name"] for f in files]
with open(path, encoding="utf-8") as f: # 默认仍落在 core.log(接口按 mtime 倒序返回,names[0] 会随当前哪个模块在
content = "".join(f.readlines()[-lines:]) # 写而漂移,作为"打开日志页时看哪个"不稳定)
except Exception as e: default = "core.log" if "core.log" in names else (names[0] if names else "")
content = f"读取失败: {e}" current = request.args.get("file") or default
return jsonify({"ok": True, "content": content, "file": current, "files": files}) try:
lines = int(request.args.get("lines", 300))
except (TypeError, ValueError):
lines = 300
res = logger.query_log(current, limit=lines, **_log_filters())
return jsonify({"ok": not res["error"], "error": res["error"], "file": current,
"files": files, "rows": res["rows"], "matched": res["matched"],
"scanned": res["scanned"], "truncated": res["truncated"]})
@bp.route("/api/logs/download")
@perm_required(PERM_LOGS)
def api_logs_download():
"""下载日志文件(带同样的过滤条件;无条件时就是整个文件)。
用 BytesIO 发送而不是 send_file(路径):Windows 上流式发送时文件句柄可能
到 close 仍未释放,而日志文件正被日志线程持续写入,按路径发容易踩锁。
"""
name = request.args.get("file", "")
text, err = logger.read_log_text(name, **_log_filters())
if err:
return jsonify({"ok": False, "error": err}), 404
data = text.encode("utf-8")
fname = f"{name}_{time.strftime('%Y%m%d_%H%M%S')}.txt"
_log.info("下载日志: %s(%d 字节)by %s", name, len(data),
getattr(current_user, "username", ""))
return send_file(BytesIO(data), as_attachment=True, download_name=fname,
mimetype="text/plain; charset=utf-8")
# ================== API:任务步骤明细 ==================
#
# 数据源是 task_step_log 表(结构化),不是 logs/task.log 文本——所以能按设备/
# 任务/时间/结果过滤。写入侧见 core/step_log.py(异步写线程 + 保留期清理)。
def _step_filters():
"""从 query string 取步骤明细的过滤条件(列表与下载共用)。"""
return dict(serial=request.args.get("serial", ""),
job_id=request.args.get("job_id", ""),
result=request.args.get("result", ""),
run_id=request.args.get("run_id", ""),
keyword=request.args.get("q", ""),
since=logger.norm_ts(request.args.get("since", "")),
until=logger.norm_ts(request.args.get("until", ""), end=True))
def _step_to_dict(r):
return {"id": r.id, "run_id": r.run_id, "job_id": r.job_id,
"job_name": r.job_name, "serial": r.serial,
"device_name": r.device_name, "step_path": r.step_path,
"step_label": r.step_label, "step_type": r.step_type,
"selector": r.selector, "result": r.result, "detail": r.detail,
"duration_ms": r.duration_ms, "created_at": r.created_at}
@bp.route("/api/step_logs")
@perm_required(PERM_LOGS)
def api_step_logs():
"""任务步骤明细(分页,最近的在前)。"""
from core import step_log
try:
limit = int(request.args.get("limit", 200))
offset = int(request.args.get("offset", 0))
except (TypeError, ValueError):
limit, offset = 200, 0
rows, total = step_log.query(limit=limit, offset=offset, **_step_filters())
return jsonify({"ok": True, "rows": [_step_to_dict(r) for r in rows],
"total": total, "limit": limit, "offset": offset,
"stats": step_log.stats(),
"keep_days": step_log.KEEP_DAYS,
"max_rows_per_run": step_log.MAX_ROWS_PER_RUN})
@bp.route("/api/step_logs/runs")
@perm_required(PERM_LOGS)
def api_step_logs_runs():
"""按 run_id 归组的一次运行概览(哪次运行、几步、失败几步)。"""
from core import step_log
f = _step_filters()
rows = step_log.runs(serial=f["serial"], job_id=f["job_id"],
since=f["since"], until=f["until"],
limit=request.args.get("limit", 100))
return jsonify({"ok": True, "runs": [
{"run_id": r.run_id, "job_id": r.job_id, "job_name": r.job_name,
"serial": r.serial, "device_name": r.device_name,
"started_at": r.started_at, "ended_at": r.ended_at,
"steps": int(r.steps or 0), "failures": int(r.failures or 0)}
for r in rows]})
@bp.route("/api/step_logs/filters")
@perm_required(PERM_LOGS)
def api_step_logs_filters():
"""筛选下拉的可选项:**只在明细里出现过的**设备与任务(按数据自洽,不依赖
设备池/任务表,也不要求调用方另有 task/device 权限)。"""
from core import step_log
from core.models import TaskStepLog, db
devices = (db.session.query(TaskStepLog.serial, TaskStepLog.device_name)
.filter(TaskStepLog.serial != "").distinct().limit(500).all())
jobs = (db.session.query(TaskStepLog.job_id, TaskStepLog.job_name)
.filter(TaskStepLog.job_id != "").distinct().limit(500).all())
return jsonify({"ok": True,
"devices": sorted(({"serial": s, "device_name": n}
for s, n in devices),
key=lambda x: x["device_name"] or x["serial"]),
"jobs": sorted(({"job_id": i, "job_name": n} for i, n in jobs),
key=lambda x: x["job_name"] or x["job_id"]),
"results": ["ok", "miss", "error", "unknown", "skip", "cap"],
"keep_days": step_log.KEEP_DAYS,
"max_rows_per_run": step_log.MAX_ROWS_PER_RUN})
@bp.route("/api/step_logs/download")
@perm_required(PERM_LOGS)
def api_step_logs_download():
"""导出步骤明细为 CSV(带同样的过滤条件)。
加 UTF-8 BOM:不加的话 Excel 打开中文是乱码(这是给运维看的表,
大概率会被 Excel 打开)。
"""
import csv
from io import StringIO
from core import step_log
f = _step_filters()
# 导出上限:和列表页共用同一次查询,最多 2 万行(再多请缩小时间范围)
rows, total = step_log.query(limit=20000, offset=0, **f)
buf = StringIO()
w = csv.writer(buf)
w.writerow(["时间", "设备", "设备名", "任务", "运行ID", "步骤路径", "步骤",
"类型", "选择器", "结果", "耗时(ms)", "详情"])
for r in reversed(rows): # 导出的时间序与页面相反:文件里按正序更好读
w.writerow([r.created_at, r.serial, r.device_name, r.job_name,
r.run_id, r.step_path, r.step_label, r.step_type,
r.selector, r.result, r.duration_ms, r.detail])
data = buf.getvalue().encode("utf-8-sig") # utf-8-sig = UTF-8 带 BOM(Excel 中文不乱码)
fname = f"step_log_{time.strftime('%Y%m%d_%H%M%S')}.csv"
_log.info("导出步骤明细: %d 行(命中 %d)by %s", len(rows), total,
getattr(current_user, "username", ""))
return send_file(BytesIO(data), as_attachment=True, download_name=fname,
mimetype="text/csv; charset=utf-8")
# ================== API:运行控制 ================== # ================== API:运行控制 ==================
+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
+59
View File
@@ -66,6 +66,19 @@ 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}")
# 任务步骤明细:起一个专用写线程,任务线程只入队(见 core/step_log.py)
try:
from core import step_log as _step_log
_step_log.init_app(app)
except Exception as _e:
_log.warning(f"步骤明细模块初始化失败(不影响启动): {_e}")
# 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件) # 消费「待生效的备份恢复」——必须在 init_db 之后(表已建好,且现在是事务替换而非换文件)
_consume_pending_restore() _consume_pending_restore()
# 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」, # 库环境标签校验 + 启动横幅:每次启动都明确写出「现在连的是哪个库」,
@@ -97,6 +110,24 @@ from web.auth import _csrf_protect, _csrf_token
context.init(mgr, apk_mgr, device_pool) context.init(mgr, apk_mgr, device_pool)
register_blueprints(app) register_blueprints(app)
def _purge_step_log():
"""清理超期的任务步骤明细(启动时 + 每日 04:13)。
app context 由本函数自建:APScheduler 的作业跑在调度线程里,没有请求上下文。
"""
try:
from core.step_log import purge_old
with app.app_context():
purge_old()
except Exception as e:
_log.warning(f"步骤明细清理失败(不影响主服务): {e}")
def _purge_step_log_async():
"""启动时后台清理一次(不阻塞启动;服务若频繁重启,定时任务可能一直轮不到)。"""
threading.Thread(target=_purge_step_log, daemon=True).start()
# 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。 # 经验库每日 AI 巡检(凌晨 03:47):评审标记疑似问题经验,删除只走人工确认。
# 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。 # 巡检线程在 run_experience_audit 内自建 app context,不依赖这里。
try: try:
@@ -105,6 +136,9 @@ try:
from web.agent_api import run_experience_audit from web.agent_api import run_experience_audit
_audit_sched = BackgroundScheduler(timezone="Asia/Shanghai") _audit_sched = BackgroundScheduler(timezone="Asia/Shanghai")
_audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47)) _audit_sched.add_job(run_experience_audit, CronTrigger(hour=3, minute=47))
# 步骤明细清理:超过保留期(core/step_log.KEEP_DAYS)的记录每天删一次。
# 不清理的话这张表是唯一无界增长的表,会一路顶大备份包。
_audit_sched.add_job(_purge_step_log, CronTrigger(hour=4, minute=13))
_audit_sched.start() _audit_sched.start()
_log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)") _log.info("经验库每日巡检已注册(03:47 Asia/Shanghai)")
except Exception as e: except Exception as e:
@@ -287,10 +321,35 @@ if __name__ == "__main__":
_ensure_uiauto_running() _ensure_uiauto_running()
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复 # 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
_preconnect_pool_devices() _preconnect_pool_devices()
# 步骤明细清理:服务频繁重启时定时任务可能一直轮不到,启动也清一次
_purge_step_log_async()
_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
try:
from core import step_log as _step_log
_step_log.shutdown(timeout=3.0) # 排空队列后再退
except Exception:
pass
mgr.shutdown() mgr.shutdown()
device_discovery.shutdown() device_discovery.shutdown()
_stop_uiauto() _stop_uiauto()