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:
2026-09-24 10:55:29 +08:00
parent 5c88cc6384
commit 876224f876
21 changed files with 803 additions and 54 deletions
+28
View File
@@ -590,6 +590,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<button class="sub-tab active" data-sub="plan" onclick="showSubTab('tasks','plan')">任务计划</button>
<button class="sub-tab" data-sub="actions" onclick="showSubTab('tasks','actions')">自定义动作</button>
<button class="sub-tab" data-sub="actioncfg" onclick="showSubTab('tasks','actioncfg')">动作配置</button>
<button class="sub-tab" data-sub="dedup" onclick="showSubTab('tasks','dedup')">去重记录</button>
</div>
<!-- 子分栏 1:任务计划 -->
@@ -849,6 +850,32 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div>
<div id="act-lib-list" style="flex:1;overflow-y:auto;min-height:200px;display:flex;flex-direction:column;gap:8px"></div>
</div>
<!-- 子分栏 4:去重记录(done_mark 表 = 跨设备"已做过"账本) -->
<div class="sub-panel" id="tasks-sub-dedup">
<div class="help">
<b>去重账本</b>:任务里用「条件判断 → 去重」+「记为已做」两个步骤,把"这个身份做过了"记在这里,
<b>所有设备共享一份</b>——所以多个手机跑同一个任务、反复重跑,也不会把同一个号做两次。<br>
<span class="text-muted">想让它重跑:删掉对应那条(单个号/单台设备)或「清空本任务记录」。
有效期在任务编辑器的<b>「去重有效期」</b>里设(每天一次 / 只做一次 / 按小时);「只做一次」的记录不会被自动清理。</span>
</div>
<div class="toolbar" style="gap:10px;flex-wrap:wrap">
<select class="form-control" id="dedup-job" style="width:280px" onchange="loadDedup()">
<option value="">全部任务</option>
</select>
<button class="btn" onclick="loadDedup()">刷新</button>
<button class="btn btn-danger" onclick="clearJobDedup()" data-perm="tasks">清空本任务记录</button>
<span class="text-muted" id="dedup-stats"></span>
</div>
<table class="table">
<thead><tr>
<th style="width:160px">时间</th><th style="width:90px">设备</th>
<th style="width:150px">身份值</th><th style="width:110px">有效期</th>
<th>任务</th><th style="width:120px">操作</th>
</tr></thead>
<tbody id="tb-dedup"><tr><td colspan="6" class="empty">暂无记录</td></tr></tbody>
</table>
</div>
</div>
<!-- ========== Tab 4: 日志 ========== -->
@@ -1453,5 +1480,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
<script src="/static/admin/notify.js"></script>
<script src="/static/admin/steplog.js"></script>
<script src="/static/admin/recorder.js"></script>
<script src="/static/admin/dedup.js"></script>
</body>
</html>