用户场景(他原话):一台手机登录 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 拉必然冲突——这份是超集,合一次两份都进。)
86 lines
4.0 KiB
JavaScript
86 lines
4.0 KiB
JavaScript
// 去重记录(「任务 → 去重记录」页)
|
|
// 账本在 done_mark 表(跨设备幂等),后端见 core/dedup.py + web/tasks_api.py 的三个接口。
|
|
// 这一页的价值:**"谁做过了、还差谁"看得见**——用户原来不知道任务什么时候跑完,
|
|
// 只能一直重跑,而重跑本身又在制造重复。
|
|
var _dedupMarks = [];
|
|
|
|
function loadDedup(){
|
|
var sel=document.getElementById('dedup-job');
|
|
if(!sel)return;
|
|
var jobId=sel.value||'';
|
|
apiGet('/api/done_marks?limit=300'+(jobId?('&job='+encodeURIComponent(jobId)):'')).then(function(r){
|
|
if(!r||!r.ok){
|
|
var tb=document.getElementById('tb-dedup');
|
|
if(tb)tb.innerHTML='<tr><td colspan="6" class="empty">读取失败:'+esc((r&&r.error)||'')+'</td></tr>';
|
|
return;
|
|
}
|
|
_dedupMarks=r.marks||[];
|
|
renderDedupJobs(r.job_id||'');
|
|
renderDedup(r.stats||{});
|
|
});
|
|
}
|
|
|
|
// 任务下拉:用已加载的任务列表填(含"全部任务");保持当前选中项
|
|
// `_jobsData` 是全局任务列表(monitor.js 声明、tasks.js 的 loadTasks 填充)
|
|
function renderDedupJobs(current){
|
|
var sel=document.getElementById('dedup-job');
|
|
if(!sel)return;
|
|
var jobs=_jobsData||[];
|
|
if(!jobs.length)return; // 任务列表还没加载就先留着旧选项
|
|
var cur=current!==undefined?current:(sel.value||'');
|
|
var html='<option value="">全部任务</option>';
|
|
jobs.forEach(function(j){
|
|
html+='<option value="'+esc(j.id)+'">'+esc(j.name||j.id)+'</option>';
|
|
});
|
|
sel.innerHTML=html;
|
|
sel.value=cur;
|
|
}
|
|
|
|
function renderDedup(stats){
|
|
var tb=document.getElementById('tb-dedup');
|
|
if(!tb)return;
|
|
var st=document.getElementById('dedup-stats');
|
|
if(st){
|
|
st.textContent='共 '+stats.total+' 条 · 涉及 '+stats.devices+' 台设备 / '
|
|
+stats.identities+' 个身份值|今天已做 '+stats.today_devices+' 台 · '
|
|
+stats.today_identities+' 个身份值';
|
|
}
|
|
if(!_dedupMarks.length){
|
|
tb.innerHTML='<tr><td colspan="6" class="empty">暂无记录(任务里用了「去重」条件 + 「记为已做」并成功做完之后才会有)</td></tr>';
|
|
return;
|
|
}
|
|
var KIND={day:'每天一次',all:'只做一次',hours:'按小时'};
|
|
tb.innerHTML=_dedupMarks.map(function(m){
|
|
var who=m.device_name?('<strong>'+esc(m.device_name)+'</strong><br>'
|
|
+'<span style="font-size:11px;color:#94a3b8;font-family:var(--mono)">'+esc(m.serial)+'</span>')
|
|
:('<span style="font-family:var(--mono)">'+esc(m.serial||'-')+'</span>');
|
|
return '<tr><td style="font-size:12px">'+esc(m.created_at)+'</td>'+
|
|
'<td>'+who+'</td>'+
|
|
'<td style="font-family:var(--mono)">'+esc(m.identity||'-')+'</td>'+
|
|
'<td><span class="label label-default">'+esc(KIND[m.kind]||m.kind)+'</span></td>'+
|
|
'<td>'+esc(m.job_name||m.job_id||'-')+'</td>'+
|
|
'<td><button class="btn btn-xs btn-danger" data-id="'+m.id+'" '+
|
|
'onclick="deleteDedupMark(this.dataset.id)" data-perm="tasks" '+
|
|
'title="删掉这条 → 这台设备/这个身份下次会重新执行">删除</button></td></tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function deleteDedupMark(id){
|
|
if(!confirm('删掉这条去重记录?\n\n删掉之后,这台设备/这个身份下次跑该任务时会重新执行一遍。'))return;
|
|
apiPost('/api/done_marks/delete',{id:id}).then(function(r){
|
|
if(r&&r.ok){showToast(r.msg||'已删除','success');loadDedup();}
|
|
else showToast('删除失败: '+((r&&r.error)||''),'error');
|
|
});
|
|
}
|
|
|
|
function clearJobDedup(){
|
|
var sel=document.getElementById('dedup-job');
|
|
var jobId=sel?sel.value:'';
|
|
if(!jobId){showToast('先在左边选一个任务("清空本任务记录"要指定任务)','error');return;}
|
|
if(!confirm('清空这个任务的全部去重记录?\n\n清空之后,所有设备/身份下次跑该任务都会重新执行一遍。'))return;
|
|
apiPost('/api/done_marks/clear',{job:jobId}).then(function(r){
|
|
if(r&&r.ok){showToast(r.msg||'已清空','success');loadDedup();}
|
|
else showToast('清空失败: '+((r&&r.error)||''),'error');
|
|
});
|
|
}
|