feat(去重): 跨设备「已做过」账本 —— 同一个号不会做两次 + 「谁做过了」看得见
用户场景(他原话):一台手机登录 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 拉必然冲突——这份是超集,合一次两份都进。)
This commit is contained in:
+88
-9
@@ -41,7 +41,7 @@ from core.device_worker import BaseWorker, _update_status
|
||||
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 import notifier, step_log, humanize, patrol, gesture
|
||||
from core import notifier, step_log, humanize, patrol, gesture, dedup
|
||||
|
||||
_log = get_logger("task.generic")
|
||||
|
||||
@@ -113,7 +113,11 @@ STEP_TYPES = [
|
||||
"params": {"children": []}},
|
||||
{"type": "if_el", "label": "条件判断", "icon": "❓",
|
||||
"params": {"selector_type": "xpath", "selector_value": "", "timeout": 3,
|
||||
"cmp_op": "", "cmp_value": "", "then": [], "else": []}},
|
||||
"cmp_op": "", "cmp_value": "",
|
||||
"ident_type": "text", "ident_value": "",
|
||||
"then": [], "else": []}},
|
||||
{"type": "mark_done", "label": "记为已做", "icon": "✅",
|
||||
"params": {"selector_type": "text", "selector_value": ""}},
|
||||
{"type": "notify", "label": "发通知", "icon": "🔔",
|
||||
"params": {"title": "", "message": "", "level": "info"}},
|
||||
{"type": "stop_self", "label": "停止本设备", "icon": "⛔",
|
||||
@@ -125,6 +129,11 @@ STEP_TYPES = [
|
||||
|
||||
DEFAULT_PARAMS = {
|
||||
"max_duration": 0, # 最大运行时长(秒),0=不限时
|
||||
# 去重账本的有效期(任务级,**只在这一处配**)。检查与记账两处各填一份的话,
|
||||
# 填不一致就会算出两个不同的 key、去重静默失效——所以强制任务级。
|
||||
# day(默认,每天一次)/ all(只做一次,永不清理)/ hours(每 dedup_hours 小时)
|
||||
"dedup_reset": "day",
|
||||
"dedup_hours": 6,
|
||||
# 注意:**没有默认 steps**。步骤由前端步骤编辑器编排产出,新建任务时从零开始拖;
|
||||
# 缺 steps 的任务执行时会明确报错(见 run_task),不会静默空跑。
|
||||
}
|
||||
@@ -658,6 +667,11 @@ class GenericStepsWorker(BaseWorker):
|
||||
|
||||
`cmp_value` **可以填多个值**(换行或 `|` 分隔,见 `split_cmp_values`),
|
||||
多个之间是 **OR**——"这几个号都算我的"。要跑多个号的场景就靠它。
|
||||
|
||||
`selector_type="dedup"` 时换成**去重判断**(跨设备幂等,见 `core/dedup.py`):
|
||||
"这个身份在这个任务里做过了吗"——命中=做过。身份元素填在 `ident_value`
|
||||
(留空则用设备 serial,适合一号一机);有效期取任务级 `dedup_reset`。
|
||||
多台手机跑同一任务时,任何一台做过了,其余设备的这一步都会命中。
|
||||
返回 True=命中 / False=未命中 / None=缺选择器或 OCR 不可用。
|
||||
"""
|
||||
sel_type = params.get("selector_type", "xpath")
|
||||
@@ -665,16 +679,28 @@ class GenericStepsWorker(BaseWorker):
|
||||
timeout = float(params.get("timeout", 3))
|
||||
cmp_op = str(params.get("cmp_op") or "").strip()
|
||||
cmp_val = str(params.get("cmp_value") or "")
|
||||
if not sel_val:
|
||||
# 去重条件不用 selector_value(身份元素在 ident_value 里),所以别按"缺选择器"拦
|
||||
if not sel_val and sel_type != "dedup":
|
||||
_log.warning(f"[{self.serial}] if_el 缺少选择器,跳过")
|
||||
return None
|
||||
found = False
|
||||
got = None # 比对用的"采集到的值":元素文本 / OCR 命中文本 / 前台包名
|
||||
# 非元素类条件(屏幕状态/前台 App):不参与"选择器健康"统计——它们不是选择器,
|
||||
# 非元素类条件(屏幕状态/前台 App/去重):不参与"选择器健康"统计——它们不是选择器,
|
||||
# 连续未命中不该被记成"选择器失效"
|
||||
dynamic = sel_type in ("screen", "foreground")
|
||||
dynamic = sel_type in ("screen", "foreground", "dedup")
|
||||
detail = ""
|
||||
if sel_type == "screen":
|
||||
if sel_type == "dedup":
|
||||
# 去重条件:**这个身份在这个任务里做过了吗**(跨设备共享一份账本,
|
||||
# 见 core/dedup.py)。命中=做过 → 走 then 分支(通常放"跳过/停止/发通知")。
|
||||
kind, hours = self._dedup_policy()
|
||||
ident = self._dedup_identity(d, params, "ident_type", "ident_value")
|
||||
if ident is None:
|
||||
found = False # 身份判定不了 → 当"没做过",照常往下做(宁可重复不误拦)
|
||||
detail = "去重:身份读不到,按未做过处理"
|
||||
else:
|
||||
found = dedup.check(self.ctx.get("job_id", ""), ident, kind, hours)
|
||||
detail = f"去重:身份『{ident}』{'做过' if found else '没做过'}(有效期 {kind})"
|
||||
elif sel_type == "screen":
|
||||
# 屏幕亮/熄:selector_value 填 on / off(也认 熄屏/灭屏 这类中文)
|
||||
want = str(sel_val).strip().lower()
|
||||
want_off = want in ("off", "0", "false", "no", "熄屏", "灭屏", "黑屏")
|
||||
@@ -727,7 +753,7 @@ class GenericStepsWorker(BaseWorker):
|
||||
existed = found
|
||||
|
||||
# ---- 文本比对(可选)----
|
||||
if cmp_op and sel_type != "screen":
|
||||
if cmp_op and sel_type not in ("screen", "dedup"):
|
||||
if not found:
|
||||
# 元素都没找到就没什么可比的:算未命中(走 else 分支)。
|
||||
# 这条规则很重要——"没读到"永远不能被当成"和我设的不一样",
|
||||
@@ -746,8 +772,8 @@ class GenericStepsWorker(BaseWorker):
|
||||
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}] 屏幕状态没有文本可比,已忽略文本比对设置")
|
||||
elif cmp_op and sel_type in ("screen", "dedup"):
|
||||
_log.warning(f"[{self.serial}] {sel_type} 没有文本可比,已忽略文本比对设置")
|
||||
|
||||
if dynamic:
|
||||
_log.info(f"[{self.serial}] 条件判断 {detail} → {'命中' if found else '未命中'}")
|
||||
@@ -763,6 +789,59 @@ class GenericStepsWorker(BaseWorker):
|
||||
self._exec_steps(d, branch, depth + 1)
|
||||
return found
|
||||
|
||||
def _dedup_policy(self):
|
||||
"""任务级去重有效期(`dedup_reset` / `dedup_hours`)→ (kind, hours)。
|
||||
|
||||
**刻意放任务级**:检查与记账两处各填一份的话,填不一致就会算出两个不同的 key、
|
||||
去重静默失效(最难查的那种 bug)。认不出的值一律回退 `day`。
|
||||
"""
|
||||
p = self.params or {}
|
||||
kind = str(p.get("dedup_reset") or dedup.KIND_DAY).strip()
|
||||
if kind not in dedup.RESET_KINDS:
|
||||
kind = dedup.KIND_DAY
|
||||
try:
|
||||
hours = int(p.get("dedup_hours") or dedup.DEFAULT_HOURS)
|
||||
except (TypeError, ValueError):
|
||||
hours = dedup.DEFAULT_HOURS
|
||||
return kind, max(1, hours)
|
||||
|
||||
def _dedup_identity(self, d, params, tkey, vkey):
|
||||
"""算去重身份:填了身份元素就读它的文本;留空则用设备 serial(一号一机场景)。
|
||||
|
||||
返回 None = **无法判定**(读不到值)→ 调用方必须"不去重"。
|
||||
宁可漏拦一次,也不能把"读不到"当成空身份——那会让所有设备共用一个 key,
|
||||
第一台记账之后其余全被误跳过(同一个号一台都没做,却被判"做过")。
|
||||
"""
|
||||
sel_val = (params.get(vkey) or "").strip()
|
||||
if not sel_val:
|
||||
return self.serial
|
||||
txt = self._read_element_text(d, params.get(tkey) or "text", sel_val)
|
||||
if not txt:
|
||||
_log.warning(f"[{self.serial}] 去重身份元素读不到值({sel_val})→ 本次不去重")
|
||||
return None
|
||||
return txt
|
||||
|
||||
def _exec_mark_done(self, d, params, depth=0):
|
||||
"""「记为已做」:把身份值记进去重账本(跨设备幂等)。
|
||||
|
||||
**放在动作成功之后**:动作失败就不记账,下次重跑会重试该设备——
|
||||
拆成"检查在前、记账在后"两步,天然就是"成功才记账、失败不丢"。
|
||||
身份读不到时**不记账**(记一个空身份会把别人挡住)。
|
||||
"""
|
||||
kind, hours = self._dedup_policy()
|
||||
ident = self._dedup_identity(d, params, "selector_type", "selector_value")
|
||||
if ident is None:
|
||||
self.set_action("记为已做:身份读不到 → 本次不记(下次重跑会重试)")
|
||||
return True
|
||||
first = dedup.mark(self.ctx.get("job_id", ""), ident, kind, hours,
|
||||
serial=self.serial,
|
||||
device_name=self.ctx.get("device_name", ""),
|
||||
job_name=self.ctx.get("job_name", ""))
|
||||
who = "首次" if first else "已存在(别处先记过)"
|
||||
_log.info(f"[{self.serial}] 记为已做:身份『{ident}』 → {who}")
|
||||
self.set_action(f"记为已做:『{ident}』{who}")
|
||||
return True
|
||||
|
||||
def _read_element_text(self, d, sel_type, sel_val):
|
||||
"""取选中元素的文本(文本比对用)。读不到返回 ""。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user