From 5c88cc638407a3deb1f601329fe00509941b5db1 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Thu, 24 Sep 2026 10:40:08 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feat(=E6=9D=A1=E4=BB=B6=E5=88=A4=E6=96=AD):?=
=?UTF-8?q?=20=E6=AF=94=E5=AF=B9=E7=9A=84=E5=80=BC=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=E5=A4=9A=E4=B8=AA=EF=BC=88=E4=BB=BB=E4=B8=80=E5=91=BD=E4=B8=AD?=
=?UTF-8?q?=E5=8D=B3=E5=8F=AF=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
需求:`35377983067` 或 `35377983068` 都算我的号,要能一次列好几个。
- `cmp_value` 现在是**多值**:换行或 `|` 分隔,一行一个。
多个之间是 **OR**(任一命中即命中);否定式(不等于/不包含)语义是
"一个都不许出现"。**逗号刻意不当分隔符**——要比对的文本本身就常带逗号
(`抖音号:123,456`),拆了就永远匹配不上。
- 比对核心抽成 `_cmp_hit(got, op, want) -> (是否命中, 命中的那个值)`,
`_text_matches` 保留布尔版(多处调用只看结果,不改签名)。
日志因此能写清人话:
`采集到 '抖音号:35377983068' 包含 2 个候选值 → 符合,命中『35377983068』`
否定式不成立时则指出"因为出现了『xxx』"——排查时一眼看出是哪个值惹的。
- 行为统一(**刻意的变更**):候选值为空/全空白 = 误配 → 一律不命中。
以前"等于 + 两边都空"会返回 True(`"" == ""` 的巧合),等于选了"等于"
却没填值就永远命中、条件判断形同虚设;现在与编辑器告警、AI 草稿校验
的口径一致了("选了运算符没填值 → 永远不命中")。
- 前端:`cmp_value` 由单行输入换成**两行文本域**,标签与提示写明"可多行、
任一命中即可、逗号不是分隔符"。
- 文档:TASK_DEV.md §4.2 例子改成多号,并写明分隔符与 OR/否定语义。
自测:多值语义 15 项(换行/`|` 拆分、逗号不拆、空白清理、四种运算符的多值
OR 与否定语义、命中的是哪个值、候选全空不命中)+ 原有 38 项回归全过;
`_exec_if_el` 走多值路径实测命中 then 分支、日志含候选数与命中值。
---
doc/TASK_DEV.md | 16 ++++++--
static/admin/editor.js | 9 +++--
tasks/generic/task.py | 83 ++++++++++++++++++++++++++++++++----------
3 files changed, 82 insertions(+), 26 deletions(-)
diff --git a/doc/TASK_DEV.md b/doc/TASK_DEV.md
index eff395c..093f0e7 100644
--- a/doc/TASK_DEV.md
+++ b/doc/TASK_DEV.md
@@ -244,18 +244,26 @@ from .generic import task # 触发 @register_task(当前唯一任务类型)
`cmp_op` 取值:`等于` / `不等于` / `包含` / `不包含`(`==`/`!=`/`contains` 这类别名也认)。
不填 = 老行为(只看元素在不在)。
+**`cmp_value` 可以填多个值**(换行或 `|` 分隔,一行一个):多个之间是 **OR**——
+"这几个号都算我的",任一命中即命中;否定式(不等于/不包含)则是"**一个都不许出现**"。
+⚠ **逗号不是分隔符**("抖音号:123,456" 这种文本本身带逗号,拆了就永远匹配不上)。
+
例子(抓元素拿到的是 `android.widget.TextView text:抖音号:35377983067 id:com.ss.android.ugc.aweme:id/506`):
```json
{"type": "if_el", "params": {
"selector_type": "resourceId", "selector_value": "com.ss.android.ugc.aweme:id/506",
- "cmp_op": "包含", "cmp_value": "35377983067", "timeout": 3,
- "then": [{"type": "stop_self", "params": {"reason": "登录的不是目标抖音号"}}],
+ "cmp_op": "包含",
+ "cmp_value": "35377983067\n35377983068", // 多个号,任一命中即可
+ "timeout": 3,
+ "then": [{"type": "stop_self", "params": {"reason": "登录的不是我的号"}}],
"else": []}}
```
-要判断"**不是**这个号"就用 `不等于`(`cmp_op=不等于` 时"界面上是别的号"会命中,正好用来抓
-"号被换/掉登录")。
+要判断"**不是**我的号"就用 `不等于`("界面上是别的号"会命中,正好用来抓"号被换/掉登录")。
+
+日志会写清"采集到什么、几个候选、命中的是哪个值",例如:
+`采集到 '抖音号:35377983068' 包含 2 个候选值 → 符合,命中『35377983068』`。
**两条必须知道的规则**(都为了不误判):
diff --git a/static/admin/editor.js b/static/admin/editor.js
index 4557ce3..780bdc9 100644
--- a/static/admin/editor.js
+++ b/static/admin/editor.js
@@ -431,9 +431,11 @@ var _stepEditor={
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
.map(function(o){return '';}).join('')+
''+
- '
'+
- '
';
+ '';
h+=_selRowHtml(p,
(dyn?'':''+
''+
@@ -447,6 +449,7 @@ var _stepEditor={
+'
文本比对(可选):填了就把找到的那个元素的文本和"比对的值"比,'
+'比中了才算命中。例:元素文本是 抖音号:35377983067,'
+'想判断"这台登录的是不是这个号",选包含、值填 35377983067 即可(要整段一模一样才用"等于")。'
+ +'多个值一行一个,任一命中即可——"这几个号都算我的"。'
+'
⚠ 元素没找到时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。')+'
', true);
}
// 共享:触发概率(所有步骤通用,0~100)
diff --git a/tasks/generic/task.py b/tasks/generic/task.py
index 69bbe51..016ccb4 100644
--- a/tasks/generic/task.py
+++ b/tasks/generic/task.py
@@ -145,26 +145,62 @@ _CMP_ALIASES = {
CMP_OPS = tuple(_CMP_ALIASES)
-def _text_matches(got, op, want):
- """元素文本比对:got(采集到的)对 want(用户设的)做 op 判断。
-
- op 见 `_CMP_ALIASES`(等于/不等于/包含/不包含)。认不出的 op 一律返回 False
- ——条件判断"看不懂就算不命中",比猜一个更容易排查。
- """
- got = (got or "").strip()
- want = (want or "").strip()
+def _canon_op(op):
+ """运算符规范化成中文那个(认别名);认不出返回 None。"""
key = (op or "").strip().lower()
for canon, aliases in _CMP_ALIASES.items():
if key in [a.lower() for a in aliases]:
- if canon == "等于":
- return got == want
- if canon == "不等于":
- return got != want
- if canon == "包含":
- # 想匹配空串等于"永远命中",那是误配,当不命中处理
- return bool(want) and want in got
- return bool(want) and want not in got
- return False
+ return canon
+ return None
+
+
+def split_cmp_values(raw):
+ """把"比对的值"拆成多个候选(**任一命中即可**,即 OR 语义)。
+
+ 分隔符只有两个:**换行** 和 `|`。
+ **刻意不拆英文/中文逗号**——要比对的文本本身就常常带逗号
+ (如 `抖音号:123,456`),拆了就永远匹配不上。界面上是一行一个。
+ """
+ if raw is None:
+ return []
+ s = str(raw).replace("\r\n", "\n").replace("\r", "\n").replace("|", "\n")
+ return [v.strip() for v in s.split("\n") if v.strip()]
+
+
+def _cmp_hit(got, op, want):
+ """核心比对:返回 (是否命中, 命中的那个值/惹事的值)。
+
+ 多个候选值之间是 **OR**("这个号或那个号都算我"):
+ · 等于 → 文本等于**任一**候选即命中
+ · 包含 → 文本含**任一**候选即命中
+ · 不等于 → 文本**不在**任何候选里才命中
+ · 不包含 → **一个**候选都不出现才命中
+ 认不出的运算符、或候选全为空 → 一律不命中("看不懂就算不命中",比猜一个更好排查)。
+ """
+ got = (got or "").strip()
+ wants = split_cmp_values(want)
+ canon = _canon_op(op)
+ if canon is None or not wants:
+ return False, ""
+ if canon == "等于":
+ hit = got in wants
+ return hit, (got if hit else "")
+ if canon == "包含":
+ for v in wants:
+ if v in got:
+ return True, v
+ return False, ""
+ if canon == "不等于":
+ return (got not in wants), ""
+ for v in wants: # 不包含
+ if v in got:
+ return False, v # 命中了"不该出现"的那个 -> 条件不成立
+ return True, ""
+
+
+def _text_matches(got, op, want):
+ """元素文本比对(布尔版,多处调用只看结果)。"""
+ return _cmp_hit(got, op, want)[0]
class GenericStepsWorker(BaseWorker):
@@ -619,6 +655,9 @@ class GenericStepsWorker(BaseWorker):
先按选择器**取到元素的文本**,再拿它和 cmp_value 比(等于/不等于/包含/不包含),
比对结果才是命中与否。例:`resourceId=com.ss.android.ugc.aweme:id/506` 的文本是
`抖音号:35377983067`,cmp_op=包含、cmp_value=35377983067 就是"这台上登录的是它"。
+
+ `cmp_value` **可以填多个值**(换行或 `|` 分隔,见 `split_cmp_values`),
+ 多个之间是 **OR**——"这几个号都算我的"。要跑多个号的场景就靠它。
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
"""
sel_type = params.get("selector_type", "xpath")
@@ -698,8 +737,14 @@ class GenericStepsWorker(BaseWorker):
else:
if got is None:
got = self._read_element_text(d, sel_type, sel_val)
- hit = _text_matches(got, cmp_op, cmp_val)
- detail = f"采集到 '{got}' {cmp_op} '{cmp_val}' → {'符合' if hit else '不符合'}"
+ hit, hit_val = _cmp_hit(got, cmp_op, cmp_val)
+ n_val = len(split_cmp_values(cmp_val))
+ if hit:
+ extra = f",命中『{hit_val}』" if hit_val else ""
+ else:
+ extra = f",因为出现了『{hit_val}』" if hit_val else ""
+ detail = (f"采集到 '{got}' {cmp_op} {n_val} 个候选值 → "
+ f"{'符合' if hit else '不符合'}{extra}")
found = hit
elif cmp_op and sel_type == "screen":
_log.warning(f"[{self.serial}] 屏幕状态没有文本可比,已忽略文本比对设置")
From 876224f876928e8134d2f7e72200c939b7207f87 Mon Sep 17 00:00:00 2001
From: butubb <1422726308@qq.com>
Date: Thu, 24 Sep 2026 10:55:29 +0800
Subject: [PATCH 2/2] =?UTF-8?q?feat(=E5=8E=BB=E9=87=8D):=20=E8=B7=A8?=
=?UTF-8?q?=E8=AE=BE=E5=A4=87=E3=80=8C=E5=B7=B2=E5=81=9A=E8=BF=87=E3=80=8D?=
=?UTF-8?q?=E8=B4=A6=E6=9C=AC=20=E2=80=94=E2=80=94=20=E5=90=8C=E4=B8=80?=
=?UTF-8?q?=E4=B8=AA=E5=8F=B7=E4=B8=8D=E4=BC=9A=E5=81=9A=E4=B8=A4=E6=AC=A1?=
=?UTF-8?q?=20+=20=E3=80=8C=E8=B0=81=E5=81=9A=E8=BF=87=E4=BA=86=E3=80=8D?=
=?UTF-8?q?=E7=9C=8B=E5=BE=97=E8=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
用户场景(他原话):一台手机登录 5 个抖音号、一共 5 台手机,每个任务只让其中一个
目标号评论;每天跑一次但不知道什么时候跑完,于是"一直重复跑" → 结果
"一个手机还没评论到,一个手机都评论两次了"。
**根因不是"单设备重复",是跨设备没有共享的判断 + 进度不可见。** 所以做两件事:
① 幂等;② 把"谁做过了、还差谁"摆到台面上(不然只能靠重跑确认,而重跑又在制造重复)。
- `core/models.py`:新表 `done_mark`(迁移账本补 v7)。**判据只有 `scope_key` 的
唯一索引**——多台设备会同时判断"没做过","先查后插"有竞态(两台都插),
唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**才原子。
- `core/dedup.py`(新):`build_key`(`任务|身份|时间桶`)/ `check` / `mark` /
`list_marks`(带"今天做了几台/几个号"统计)/ `delete_mark` / `clear_job` / `purge_old`。
自建 app context(照 device_pool 的 `_ctx()`),任务线程/Web/清理都不用关心。
- 任务侧两个部件(**检查在前、记账在后**):
· `if_el` 新增条件类型 `selector_type="dedup"`:命中=这个身份做过了 → 走 then 分支。
身份元素在 `ident_type`/`ident_value`(留空 = 用设备 serial,一号一机场景)。
· 新步骤 `mark_done`「记为已做」(22 种步骤):放动作**成功之后**。
拆两步的用意:动作失败就不记账,下次重跑还会重试该设备 —— 失败不丢。
- 有效期(`dedup_reset` = day/all/hours)放**任务级**:检查与记账两处各填一份的话,
填不一致就算出两个 key、去重会**静默失效**,所以强制只配一处(编辑器顶部下拉)。
- 三条防误伤规则(都有测试兜着):
· 身份读不到 / 身份值过长 → **不去重、当没做过照常执行**。绝不能把"读不到"
当成空身份——那会让所有设备共用一个 key、第一台记账后其余全被误判成"做过"。
· `kind='all'`(只做一次)的记录**永不清理**(清了等于语义失效);清理只删 day/hours。
· 去重的两个易错点在保存时直接告警:身份元素两边不一致、有检查没记账/有记账没检查。
- 「任务 → 去重记录」新子分栏(`static/admin/dedup.js`):统计行 + 明细表 +
删单条(那个号重跑)/ 清空任务(整批重跑)。接口 3 个(GET/delete/clear,PERM_TASKS)。
- 每日 04:23 清理(挂现有 APScheduler),`TABLE_LABELS` 补中文名(备份覆盖自动派生)。
- AI 建任务草稿校验同步:`dedup` 走自己的规则(要 ident_value、xpath 前缀校验),
没填身份元素只警告不拦(用设备当身份是合法用法);普通条件空选择器仍然拦。
- 文档:TASK_DEV §4.6(去重专章 + App 内检测的兜底配方与它的三个局限)、
DATA_MODEL §2.9、API 三个接口、ARCHITECTURE(分层/装配/子分栏/JS 分工/清理)、
DEPLOY §5.2(15 张表)、步骤数 21→22 全库同步。
自测:单元 + 集成 33 项(**含 8 线程抢同一个身份、恰好一个成功**的原子性断言,
以及"all 记录不被清理""身份读不到不去重""清了能重跑")、
**真机端到端**(cs1 上"检查→动作→记账"跑两遍:第二遍被拦、换 serial 的"另一台设备"
同样被拦、删记录后能重跑)、草稿校验 5 项、GET 冒烟 56 路由 0 个 500。
(注:本分支基于 feat/if-el-multi-value,因为它俩都要改 task.py 的 STEP_TYPES 与
editor.js 的 STEP_LIB 同一区域,分开从 dev 拉必然冲突——这份是超集,合一次两份都进。)
---
README.md | 11 +-
core/dedup.py | 235 +++++++++++++++++++++++++++++++++++
core/models.py | 34 +++++
core/system_backup.py | 1 +
core/task_draft.py | 21 +++-
doc/AI_CONSOLE.md | 6 +-
doc/AI_TASK_GEN.md | 2 +-
doc/API.md | 14 +++
doc/ARCHITECTURE.md | 11 +-
doc/DATA_MODEL.md | 25 ++++
doc/DEPLOY.md | 8 +-
doc/README.md | 2 +-
doc/TASK_DEV.md | 65 +++++++++-
static/admin/base.js | 1 +
static/admin/dedup.js | 85 +++++++++++++
static/admin/editor.js | 137 +++++++++++++++++---
static/admin/tasks.js | 3 +-
tasks/generic/task.py | 97 +++++++++++++--
templates/admin/monitor.html | 28 +++++
web/tasks_api.py | 52 +++++++-
web_server.py | 19 +++
21 files changed, 803 insertions(+), 54 deletions(-)
create mode 100644 core/dedup.py
create mode 100644 static/admin/dedup.js
diff --git a/README.md b/README.md
index 957c544..fee48bd 100644
--- a/README.md
+++ b/README.md
@@ -47,11 +47,12 @@
| **设备池** | SQLite 清单 + adb 在线状态;支持手工添加、网段自动发现、一键重连、型号采集、启用/停用 |
| **电量监控** | 后台每分钟 `dumpsys battery`(只读)采一次电量,**大屏卡片**与**监控页设备列表**都显示(按档位着色 + ⚡ 充电中);低于阈值推 webhook 告警(**充电中不报、"插着却没充电"照报**,掉档/恢复各报一次,不刷屏)。电量只放内存不落库(见 [NOTIFY.md](doc/NOTIFY.md) §3.1) |
| **任务调度** | 手动 / cron 定时 / 定时启停;运行窗口;失败重试(含端口耗尽类的长退避) |
-| **步骤编辑器** | 可视化拖拽编排 21 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 |
+| **步骤编辑器** | 可视化拖拽编排 22 种步骤(含循环/条件/OCR/通知/录制回放),可打包成"自定义动作"复用 |
| **拟人化操作** | 滑动默认走弧线轨迹、位置/幅度/时长每次抖动,且**每台设备有自己的手速与习惯**(同设备风格稳定、设备之间明显不同)——批量跑时不像同步机器人(见 [TASK_DEV.md](doc/TASK_DEV.md) §8.4) |
| **录制回放** | 「录制手势」步骤 = **纯录制**:**用手指在真机上划**(读 getevent 真触屏)或在网页画面上拖,把轨迹点列原样存下来,回放时**照原路径与时长重放**——不套滑动那套方向/幅度/拟人参数 |
| **动作配置 / 录制** | 「任务 → 动作配置」:① 配**步骤默认值**(新建步骤预填:滑动时长/幅度/抖动/拟人、点击超时、等待区间…)② **录制动作**——在设备画面上点/划/按键,自动翻译成步骤(点元素记选择器、划一下记方向/幅度/时长),可存成可复用动作,也可只取一个手势回填滑动步骤 |
| **公共巡检** | 任务级的守护条件(**独立于步骤画布**):每 N 秒检查屏幕亮/熄、元素在不在、前台是不是某 App,命中就点亮/息屏/停本设备/推通知——通知标题正文自己写(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.5) |
+| **去重账本** | 解决"**同一个号被做两次、有的号还没做**":任务里用「条件判断→去重」+「记为已做」,把"这个身份做过了"记进一张**所有设备共享**的账本——多台手机、反复重跑都不会重复做同一个号。「任务 → 去重记录」页能看"谁做过了、还差谁",也能删记录让它重跑(见 [TASK_DEV.md](doc/TASK_DEV.md) §4.6) |
| **元素抓取** | 拉取设备 UI 元素树 → 点选回填选择器;支持"点一下"与"测选择器"真机验证 |
| **实时看屏** | MJPEG 实时流 + 点击/滑动/按键/文字输入;全屏监控大屏(`/wall`) |
| **应用管理** | APK 上传/解析/批量安装;设备已装应用与版本查询;剪贴板注入 |
@@ -157,7 +158,7 @@ auto_control/
├── tasks/ # 任务定义层
│ ├── base.py # BaseTask + _TASK_TYPES + register_task
│ └── generic/ # 通用步骤任务(task_type=generic_steps,当前唯一类型)
-│ └── task.py # STEP_TYPES(21 种步骤)+ Worker + 执行器
+│ └── task.py # STEP_TYPES(22 种步骤)+ Worker + 执行器
│
├── mcp_server/ # MCP Server(20 个 de_* 工具,:8033)
│ ├── mcp_server.py # 工具定义 + 平台登录 + 门控
@@ -283,14 +284,14 @@ self.set_progress(done=5, total=80, unit="视频", action_counts={"like": 3}, el
`generic_steps` 的执行内容全在 `params.steps`(JSON 数组),由步骤编辑器产出。**没有默认步骤**——空步骤任务执行时会明确报错。
-**21 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
+**22 种步骤**(完整参数见 [doc/TASK_DEV.md](doc/TASK_DEV.md)):
| 类别 | 步骤 |
|------|------|
| 屏幕 | `screen_on` 亮屏 · `screen_off` 息屏 · `keep_screen` 保持亮屏 |
| 应用与输入 | `open_app` 打开 App · `stop_app` 结束 App · `input_text` 输入文字 · `clipboard` 剪贴板注入 · `key_event` 按键 |
| 交互 | `click` 点击元素 · `click_xy` 点击坐标 · `long_click` 长按 · `swipe` 滑动 · `swipe_until` 滑动直到元素出现 · `wait_el` 等待元素 · `wait` 等待时长 |
-| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素或 **OCR 识别**) |
+| 流程 | `loop` 循环块 · `group` 动作组 · `if_el` 条件判断(元素 / **OCR 识别** / 屏幕状态 / 前台App / **去重**)· `mark_done` 记为已做(配套去重) |
要点:
@@ -405,7 +406,7 @@ read_log_text()`,`file` 参数走白名单,不接受任意路径。
| [doc/ARCHITECTURE.md](doc/ARCHITECTURE.md) | 架构详解(分层、装配顺序、线程模型、设备生命周期、调度链路、设计决策) |
| [doc/DATA_MODEL.md](doc/DATA_MODEL.md) | 数据模型(表结构、迁移、`app_meta`、数据目录、备份覆盖清单) |
| [doc/API.md](doc/API.md) | HTTP 接口全量说明 |
-| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(21 种步骤、公共巡检、录制回放、选择器、自定义动作) |
+| [doc/TASK_DEV.md](doc/TASK_DEV.md) | 任务与步骤开发(22 种步骤、公共巡检、录制回放、去重、选择器、自定义动作) |
| [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/DEPLOY.md](doc/DEPLOY.md) | 部署与运维(含生产容器、备份导入、故障排查) |
diff --git a/core/dedup.py b/core/dedup.py
new file mode 100644
index 0000000..0942d7d
--- /dev/null
+++ b/core/dedup.py
@@ -0,0 +1,235 @@
+"""「已做过」账本:跨设备幂等(评论不重复)+ 进度可见。
+
+**它解决什么**(用户场景):一台手机登录多个账号、多台手机跑同一个任务,
+每天跑一次但不知道什么时候跑完,于是反复重跑 → 同一个号被做两次、有的号还没做。
+账本给出两个东西:
+ 1. `check()` —— "这个身份在这个任务里做过了吗"(跨设备,多台手机共享同一份判断)
+ 2. `list_marks()` —— "谁做过了、还差谁"(界面上看得见,就不用靠"一直重跑"来确认)
+
+**为什么必须靠唯一索引**:多台设备可能同时判断"没做过"。
+"先 SELECT 再 INSERT"有竞态——两台都会插进去。唯一索引 + `INSERT ... ON DUPLICATE KEY`
+(MySQL)/ `INSERT OR IGNORE`(SQLite)的**受影响行数**才是原子的判据:
+`1` = 你抢到了首次,`0` = 别人已经做过。
+
+**身份值读不到时绝不去重**(`identity is None`):宁可漏拦一次,
+也不能让"读不到"退化成"空身份"——那会让所有设备共用一个 key,
+第一台记账后其余全部被误跳过。同理,身份值过长时也不截断(截断会让两个身份撞车)。
+
+有效期策略(`kind`)由任务级参数 `dedup_reset` 决定,**只在一处配**:
+步骤里各填一份的话,两处填不一致就会算出不用的 key、去重静默失效。
+
+DB 访问由本模块自建 app context(照 `core/device_pool.py` 的 `_ctx()`),
+调用方(任务线程 / Web 线程 / 每日清理)都不用关心。
+"""
+import time
+from datetime import datetime, timedelta
+
+from core.logger import get_logger
+from core.models import db
+
+_log = get_logger("core.dedup")
+
+# 有效期策略
+KIND_DAY = "day" # 每天一次(默认;"每天跑一次"的场景)
+KIND_HOURS = "hours" # 每 N 小时一次
+KIND_ALL = "all" # 只做一次(永久)
+RESET_KINDS = (KIND_DAY, KIND_ALL, KIND_HOURS)
+DEFAULT_HOURS = 6
+# 账本保留期:只影响 day/hours 桶(它们过期就没意义了);
+# **kind='all' 的记录永不清理**——清了就等于"只做一次"失效
+KEEP_DAYS = 180
+# scope_key 上限(= DoneMark.scope_key 列宽)。超了不去重、只告警:
+# 截断会让两个不同身份撞成同一个 key,进而误跳过
+MAX_KEY_LEN = 290
+
+_app = None
+
+
+def init_app(app):
+ """web_server 启动时调用:绑 app(后台线程访问 db 要自推 context)。"""
+ global _app
+ _app = app
+
+
+def _ctx():
+ if _app is None:
+ raise RuntimeError("dedup 未关联 Flask app(web_server 启动时调用 init_app)")
+ return _app.app_context()
+
+
+# ================== 唯一键 ==================
+def build_key(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS, now=None):
+ """算唯一键:`任务 | 身份 | 时间桶`。
+
+ 桶只影响"多久之后算新的一轮":
+ · day → `|d:2026-09-24`(本地日期,跟着自然日走)
+ · hours → `|h6:4939219`(epoch // (N*3600),改 N 就换了桶)
+ · all → `|all`(无桶,永远命中同一个 key)
+ """
+ now = time.time() if now is None else now
+ base = f"{(job_id or '').strip()}|{(identity or '').strip()}"
+ if kind == KIND_ALL:
+ return base + "|all"
+ if kind == KIND_HOURS:
+ h = max(1, int(hours or DEFAULT_HOURS))
+ return f"{base}|h{h}:{int(now // (h * 3600))}"
+ return base + "|d:" + time.strftime("%Y-%m-%d", time.localtime(now))
+
+
+def _key_or_none(job_id, identity, kind, hours):
+ """算 key 并做长度体检;超长返回 None(调用方按"不去重"处理,不截断)。"""
+ if not (identity or "").strip():
+ return None
+ key = build_key(job_id, identity, kind, hours)
+ if len(key) > MAX_KEY_LEN:
+ _log.warning(f"去重键过长({len(key)} 字符)→ 本次不去重,避免截断后两个身份撞车: {key[:80]}…")
+ return None
+ return key
+
+
+# ================== 查 / 记 ==================
+def check(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS):
+ """这个身份在这个任务里做过了吗?(只读)
+
+ 返回 True=做过 / False=没做过。identity 为空时返回 False(不去重,照常执行)。
+ """
+ key = _key_or_none(job_id, identity, kind, hours)
+ if key is None:
+ return False
+ try:
+ with _ctx():
+ row = db.session.execute(
+ db.text("SELECT 1 FROM done_mark WHERE scope_key = :k LIMIT 1"),
+ {"k": key}).fetchone()
+ return row is not None
+ except Exception as e:
+ # 账本读失败不能拦住任务:当"没做过"放行(宁可重复,不可卡死)
+ _log.warning(f"去重检查失败(按未做过放行): {e}")
+ return False
+
+
+def mark(job_id, identity, kind=KIND_DAY, hours=DEFAULT_HOURS,
+ serial="", device_name="", job_name=""):
+ """原子记账。返回 True=本次是首次(你抢到了)/ False=别人已经记过(或没记成)。
+
+ ⚠ **必须靠唯一索引**:不能先 check 再 insert(多台设备会同时通过)。
+ """
+ key = _key_or_none(job_id, identity, kind, hours)
+ if key is None:
+ return False
+ args = {"k": key, "kind": kind, "jid": job_id or "", "jname": job_name or "",
+ "serial": serial or "", "dname": device_name or "",
+ "ident": (identity or "").strip()[:200],
+ "ts": time.strftime("%Y-%m-%d %H:%M:%S")}
+ try:
+ with _ctx():
+ is_mysql = db.engine.dialect.name == "mysql"
+ cols = ("scope_key, kind, job_id, job_name, serial, device_name, "
+ "identity, created_at")
+ vals = (":k, :kind, :jid, :jname, :serial, :dname, :ident, :ts")
+ if is_mysql:
+ # 冲突时做一次"无变化"的更新:受影响行数 1=插入、0=已存在(MySQL 语义)
+ sql = (f"INSERT INTO done_mark ({cols}) VALUES ({vals}) "
+ f"ON DUPLICATE KEY UPDATE scope_key = scope_key")
+ else:
+ sql = f"INSERT OR IGNORE INTO done_mark ({cols}) VALUES ({vals})"
+ r = db.session.execute(db.text(sql), args)
+ db.session.commit()
+ first = (r.rowcount or 0) > 0
+ if first:
+ _log.info(f"[{serial}] 记账:任务『{job_name or job_id}』身份『{args['ident']}』"
+ f"({kind})")
+ return first
+ except Exception as e:
+ _log.warning(f"去重记账失败(本次不记,下次重跑会重试): {e}")
+ return False
+
+
+# ================== 界面:看 / 清 ==================
+def list_marks(job_id="", limit=200):
+ """去重记录(界面用):返回 (rows, stats)。
+
+ stats:`total` 本任务全部 · `today_devices` 今天已做的设备数 ·
+ `today_identities` 今天已做的身份值数(== "今天做成了几个号")。
+ """
+ limit = max(1, min(int(limit or 200), 2000))
+ today0 = time.strftime("%Y-%m-%d 00:00:00")
+ where, args = "", {}
+ if job_id:
+ where = "WHERE job_id = :jid"
+ args["jid"] = job_id
+ with _ctx():
+ rows = db.session.execute(db.text(
+ f"SELECT id, scope_key, kind, job_id, job_name, serial, device_name, "
+ f"identity, created_at FROM done_mark {where} "
+ f"ORDER BY id DESC LIMIT :lim"), {**args, "lim": limit}).fetchall()
+ twhere = where + (" AND " if where else "WHERE ") + "created_at >= :t0"
+ stat = db.session.execute(db.text(
+ f"SELECT COUNT(*) AS total, "
+ f"COUNT(DISTINCT serial) AS devs, "
+ f"COUNT(DISTINCT identity) AS ids "
+ f"FROM done_mark {where}"), args).fetchone()
+ today = db.session.execute(db.text(
+ f"SELECT COUNT(DISTINCT serial) AS devs, "
+ f"COUNT(DISTINCT identity) AS ids "
+ f"FROM done_mark {twhere}"), {**args, "t0": today0}).fetchone()
+ out = [{"id": r[0], "scope_key": r[1], "kind": r[2], "job_id": r[3], "job_name": r[4],
+ "serial": r[5], "device_name": r[6], "identity": r[7], "created_at": r[8]}
+ for r in rows]
+ stats = {"total": stat[0] if stat else 0,
+ "devices": stat[1] if stat else 0,
+ "identities": stat[2] if stat else 0,
+ "today_devices": today[0] if today else 0,
+ "today_identities": today[1] if today else 0}
+ return out, stats
+
+
+def delete_mark(mark_id):
+ """删一条记录(让某个号/某台设备能重跑)。返回是否删掉了。"""
+ try:
+ with _ctx():
+ r = db.session.execute(db.text("DELETE FROM done_mark WHERE id = :i"),
+ {"i": int(mark_id)})
+ db.session.commit()
+ return (r.rowcount or 0) > 0
+ except Exception as e:
+ _log.warning(f"删除去重记录失败: {e}")
+ return False
+
+
+def clear_job(job_id):
+ """清空某个任务的全部去重记录(整批重跑)。返回删掉的行数。"""
+ try:
+ with _ctx():
+ r = db.session.execute(db.text("DELETE FROM done_mark WHERE job_id = :j"),
+ {"j": job_id or ""})
+ db.session.commit()
+ n = r.rowcount or 0
+ _log.info(f"清空去重记录:任务 {job_id} 共 {n} 条")
+ return n
+ except Exception as e:
+ _log.warning(f"清空去重记录失败: {e}")
+ return 0
+
+
+def purge_old(keep_days=KEEP_DAYS):
+ """清理过期的 day/hours 桶记录。**kind='all' 永不清理**(清了等于去重失效)。
+
+ 量级很小(设备数 × 天数),直接一条 DELETE 就行,不用像步骤明细那样分批。
+ """
+ cutoff = (datetime.now() - timedelta(days=max(1, int(keep_days)))
+ ).strftime("%Y-%m-%d %H:%M:%S")
+ try:
+ with _ctx():
+ r = db.session.execute(db.text(
+ "DELETE FROM done_mark WHERE kind <> :all AND created_at < :cut"),
+ {"all": KIND_ALL, "cut": cutoff})
+ db.session.commit()
+ n = r.rowcount or 0
+ if n:
+ _log.info(f"去重记录清理:删除 {n} 条(保留 {keep_days} 天;"
+ f"kind={KIND_ALL} 的永不清理)")
+ return n
+ except Exception as e:
+ _log.warning(f"去重记录清理失败: {e}")
+ return 0
diff --git a/core/models.py b/core/models.py
index bddeb68..63ce3e1 100644
--- a/core/models.py
+++ b/core/models.py
@@ -403,6 +403,39 @@ class TaskStepLog(db.Model):
)
+class DoneMark(db.Model):
+ """「已做过」账本:跨设备幂等的标记(「任务 → 去重记录」页)。
+
+ 要解决的问题(用户场景):一台手机登录多个账号、多台手机跑同一个任务,
+ 任务被反复重跑(因为不知道什么时候跑完)→ 同一个号被做两次、有的号还没做。
+
+ **判据只有一条:`scope_key` 的唯一索引。**
+ 多台设备可能同时判断"没做过","先查后插"会两台都插进去;
+ 唯一索引 + `INSERT ... ON DUPLICATE KEY`/`INSERT OR IGNORE` 的**受影响行数**
+ 才是原子的(见 `core/dedup.py` 的 `mark()`)。
+
+ 写入方是任务步骤 `_exec_mark_done`(**成功之后才记账**):动作失败就不记账,
+ 下次重跑还会重试该设备——这是"失败不丢"的关键。
+
+ `kind` 是有效期策略(`day`/`hours`/`all`),清理时**只删 day/hours**:
+ `all` 代表"只做一次",删掉就等于去重失效。
+ """
+ __tablename__ = "done_mark"
+ id = db.Column(db.Integer, primary_key=True, autoincrement=True)
+ scope_key = db.Column(db.String(300), unique=True) # 幂等的全部依据(唯一索引)
+ kind = db.Column(db.String(12), default="day") # day / hours / all
+ job_id = db.Column(db.String(32), default="", index=True)
+ job_name = db.Column(db.String(120), default="")
+ serial = db.Column(db.String(120), default="")
+ device_name = db.Column(db.String(80), default="")
+ identity = db.Column(db.String(200), default="") # 身份值(如抖音号)
+ created_at = db.Column(db.String(20), default="", index=True)
+
+ __table_args__ = (
+ db.Index("ix_done_mark_job_ts", "job_id", "created_at"),
+ )
+
+
class AgentConversation(db.Model):
"""AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。"""
__tablename__ = "agent_conversation"
@@ -427,6 +460,7 @@ SCHEMA_MIGRATIONS = [
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)", None),
(5, "设备池:device 表新增 fingerprint 列(设备指纹 ro.serialno,换 IP 后认领回原记录)", None),
(6, "自动发现:pending_device 表新增 fingerprint 列(扫描时读取,用于提示是已有设备换了 IP)", None),
+ (7, "去重账本:done_mark 表(跨设备幂等的「已做过」标记,唯一索引 scope_key)", None),
]
# 当前 schema 版本(备份/恢复用它判断新旧,也写进 app_meta.schema_version)
diff --git a/core/system_backup.py b/core/system_backup.py
index 3ebbac8..ab99fa2 100644
--- a/core/system_backup.py
+++ b/core/system_backup.py
@@ -57,6 +57,7 @@ TABLE_LABELS = {
"agent_experience": "经验库", "experience_audit": "经验巡检",
"agent_action": "动作库", "device_install_log": "设备端安装记录",
"task_step_log": "任务步骤明细",
+ "done_mark": "去重记录(已做过)",
}
_STAGE_TTL = 1800 # 导入暂存有效期(秒)
diff --git a/core/task_draft.py b/core/task_draft.py
index 1cdc408..7a25e50 100644
--- a/core/task_draft.py
+++ b/core/task_draft.py
@@ -148,8 +148,10 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None,
errors.append(f"{here}.params.probability: 必须是 0~100 的数字"
f"(当前 {prob!r})")
- # 选择器类步骤
- if stype in NEED_SELECTOR:
+ # 选择器类步骤(例外:if_el 用「去重」条件时不需要 selector_value,
+ # 它的身份元素在 ident_value 里,按自己的规则校验)
+ if stype in NEED_SELECTOR and not (stype == "if_el"
+ and params.get("selector_type") == "dedup"):
sel = params.get("selector_value")
if not isinstance(sel, str) or not sel.strip():
errors.append(f"{here}.params.selector_value: 不能为空 —— "
@@ -234,6 +236,17 @@ def validate_steps(steps, depth=1, path="steps", errors=None, warnings=None,
elif (params.get("selector_type") or "xpath") == "screen":
warnings.append(f"{here}: 屏幕状态没有文本可比,"
"cmp_op/cmp_value 会被忽略")
+ # 去重条件(见 tasks/generic/task.py 的 selector_type="dedup"):
+ # 不用 selector_value,身份元素填在 ident_value
+ if (params.get("selector_type") or "") == "dedup":
+ iv = (params.get("ident_value") or "").strip()
+ if not iv:
+ warnings.append(f"{here}: 去重条件没填身份元素,将按「设备」当身份"
+ "(一号一机时没问题;一台机器多个号时要填账号那个元素)")
+ elif (params.get("ident_type") or "text") == "xpath" \
+ and not (iv.startswith("//") or iv.startswith("(//")):
+ errors.append(f"{here}.params.ident_value: 身份元素的类型是 xpath,"
+ f"取值应以 // 或 (// 开头(当前 {iv[:40]!r})")
if stype == "swipe":
_check_direction(params, here, errors)
@@ -294,9 +307,9 @@ def _check_selector(params, here, stype, errors, warnings):
stype_ok = IF_SELECTOR_TYPES if stype == "if_el" else SELECTOR_TYPES
sel_type = params.get("selector_type") or "xpath"
value = (params.get("selector_value") or "").strip()
- if sel_type not in stype_ok:
+ if sel_type not in stype_ok and not (sel_type == "dedup" and stype == "if_el"):
errors.append(f"{here}.params.selector_type: 不支持 {sel_type!r}"
- f"(可用:{'、'.join(stype_ok)})")
+ f"(可用:{'、'.join(stype_ok + ('dedup',))})")
return
if len(value) > MAX_SELECTOR_LEN:
errors.append(f"{here}.params.selector_value: 太长(>{MAX_SELECTOR_LEN} 字符)")
diff --git a/doc/AI_CONSOLE.md b/doc/AI_CONSOLE.md
index 5005238..b0bc4ca 100644
--- a/doc/AI_CONSOLE.md
+++ b/doc/AI_CONSOLE.md
@@ -184,7 +184,11 @@ mcp_agent.Agent.run_stream(prompt, serial, history, on_delta, on_tool, on_usage,
从本轮**成功**的步骤轨迹里提炼命名动作(如「打开抖音」)。硬约束:
- **禁坐标**:带 `click_xy` 的步骤不会被沉淀(坐标换个设备/分辨率就失效)
-- 白名单步骤类型(21 种去掉 `click_xy`、`keep_screen`、`gesture`)
+- 白名单步骤类型(**显式列举 16 种**,见 `web/agent_api.py` 的 `_ACTION_STEP_TYPES`):
+ `open_app` / `stop_app` / `screen_on` / `screen_off` / `key_event` / `swipe` /
+ `swipe_until` / `click` / `long_click` / `wait_el` / `input_text` / `clipboard` /
+ `wait` / `loop` / `group` / `if_el`——坐标类(`click_xy`)与流程标记类
+ (`keep_screen` / `gesture` / `notify` / `stop_self` / `mark_done`)都不沉淀
- 每类型有必填参数校验(如 `click` 必须有选择器)
- 输入/产出限量:最多 10 步输入、最多 3 个动作 × 4 步
- 保存时服务端**再校验一次**,含坐标的提交直接 400
diff --git a/doc/AI_TASK_GEN.md b/doc/AI_TASK_GEN.md
index 424239e..7fd6e46 100644
--- a/doc/AI_TASK_GEN.md
+++ b/doc/AI_TASK_GEN.md
@@ -9,7 +9,7 @@
平台已有两套能力,但互不相通:
- **AI 控制台**:一句话 + 选设备 → 多模态 Agent(DeepSeek)通过 20 个 `de_*` 工具在手机上「边看边做」(截图看屏、`de_ui_tree` 拿元素树、`de_tap_element/de_tap_text` 语义点按),流式回放步骤。
-- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…21 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
+- **任务系统 + 步骤编辑器**:`generic_steps` 任务 = 一棵可嵌套步骤树(open_app/click/swipe/loop/group/if_el…22 种节点),在编辑器里拖拽编排、单步试跑、定时调度。
目标:让**非工程用户用一句自然语言需求**(例:「创建一个每日养号刷视频的任务,每天 8:00-9:00 在 100.100.10.13 跑」)得到**一条可直接调度、可继续在现有步骤编辑器里手改的任务**。AI 先自己在设备上打开 App、看 UI 树、确认可点元素,再直接撰写编辑器的步骤 JSON。
diff --git a/doc/API.md b/doc/API.md
index 21b989f..41773ea 100644
--- a/doc/API.md
+++ b/doc/API.md
@@ -134,6 +134,9 @@
| GET | `/api/uiauto/snapshot` | D | **一次取齐**截图 + 元素树(原生 u2,抓取弹窗用) |
| GET | `/api/uiauto/screenshot` | D | uiautodev 截图(JPEG) |
| POST | `/api/steps/test` | D | 真机试执行单个步骤 |
+| GET | `/api/done_marks?job=&limit=` | L | 去重记录列表 + 统计(`{marks, stats{total,devices,identities,today_devices,today_identities}}`) |
+| POST | `/api/done_marks/delete` | T | 删一条去重记录(`{id}`)→ 该设备/身份下次会重新执行 |
+| POST | `/api/done_marks/clear` | T | 清空某任务的全部去重记录(`{job}`)→ 整批重跑 |
### 2.4 admin(`web/admin_api.py`)
@@ -468,6 +471,17 @@
`steps` 的 schema 与 `generic_steps` 的 `params.steps` 完全一致,见 [TASK_DEV.md](TASK_DEV.md)。
+### 去重记录(「任务 → 去重记录」页)
+
+| 接口 | 请求 | 响应要点 |
+|------|------|---------|
+| `GET /api/done_marks` | `?job=<任务ID>&limit=200` | `{ok, marks:[{id,scope_key,kind,job_id,job_name,serial,device_name,identity,created_at}], stats:{total,devices,identities,today_devices,today_identities}}`。`job` 省略 = 全部任务 |
+| `POST /api/done_marks/delete` | `{"id"}` | 删一条 → 该设备/身份下次会重新执行;不存在 → 404;缺 id → 400 |
+| `POST /api/done_marks/clear` | `{"job"}` | 清空该任务的全部记录(整批重跑)→ `{ok,msg,deleted}`;缺 job → 400 |
+
+账本表与语义见 [DATA_MODEL.md](DATA_MODEL.md) §2.9;任务侧怎么用见
+[TASK_DEV.md](TASK_DEV.md) §4.6。
+
---
## 6. 设备池与自动发现
diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md
index 87c3718..7ebe2f7 100644
--- a/doc/ARCHITECTURE.md
+++ b/doc/ARCHITECTURE.md
@@ -26,7 +26,7 @@
┌───────────────────────────▼──────────────────────────────────────────┐
│ 领域层 task_manager(调度) device_worker(执行) device_pool(池) │
│ system_backup(备份) apk_manager(应用) device_discovery │
-│ device_battery(电量采集 + 低电量告警) │
+│ device_battery(电量采集 + 低电量告警) dedup(去重账本) │
└───────────────────────────┬──────────────────────────────────────────┘
│
┌───────────────────────────▼──────────────────────────────────────────┐
@@ -80,13 +80,13 @@
|------|------|---------|
| **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 |
-| **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`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
+| **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`(**刷一次 `serial→名称` 内存快照** + **起线程**:3s 后采集型号、另起 `device-names` 每 60s 刷名称);`device_discovery.init_app`(**起常驻扫描线程**);`device_battery.init_app`(**起常驻电量采集线程**);`dedup.init_app`(**去重账本绑 app**,供任务线程/Web/清理自推 context);`TaskManager(app=app)`(APScheduler + 看门狗 + 从库加载分组/任务 + 重注册 cron);`ApkManager(app=app)`;`notifier.set_device_name_resolver(device_pool.name_of)`(通知里显示设备名而不是 IP,见 [NOTIFY.md](NOTIFY.md) §7) |
### 2.3 阶段 E~G:蓝图、巡检调度器、真正启动
| 阶段 | 位置 | 做了什么 |
|------|------|---------|
| **E** | `:64-67` | `context.init(...)`;`register_blueprints(app)`(10 个蓝图);`agent_api.set_app(app)`(供后台线程推 app context) |
-| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`,自建 app context);失败仅 warning |
+| **F** | 同上附近 | **第二个独立 APScheduler**:`CronTrigger(hour=3, minute=47)` 挂经验库巡检、`hour=4, minute=13` 挂步骤明细清理(`_purge_step_log`)、`hour=4, minute=23` 挂去重记录清理(`_purge_done_mark`,只清 `day`/`hours` 桶)——两个清理任务都自建 app context;失败仅 warning |
| **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()` + `device_battery.shutdown()` + 停 uiautodev |
> ⚠️ **阶段 A~F 在 import 期就会起线程/调度器**,只有 uiautodev 拉起与预连接在 `__main__` 分支。以 WSGI 方式 import 本模块会得到"半个启动"的进程——本地调试请直接 `python web_server.py`。
@@ -270,7 +270,7 @@ connecting ──获取设备──▶ u2 连接 ──▶ running ──▶ set
### 6.1 单页应用
-- 主页面 `templates/admin/monitor.html`:一个内联 `