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
+118 -19
View File
@@ -19,13 +19,20 @@ var STEP_LIB=[
{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:'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,cmp_op:'',cmp_value:'',then:[],else:[]}},
{type:'if_el',label:'条件判断',icon:'❓',cat:'flow',params:{selector_type:'xpath',selector_value:'',timeout:3,ocr_click:false,cmp_op:'',cmp_value:'',ident_type:'text',ident_value:'',then:[],else:[]}},
{type:'mark_done',label:'记为已做',icon:'✅',cat:'flow',params:{selector_type:'text',selector_value:''}},
{type:'notify',label:'发通知',icon:'🔔',cat:'flow',params:{title:'',message:'',level:'info'}},
{type:'stop_self',label:'停止本设备',icon:'⛔',cat:'flow',params:{reason:''}},
{type:'gesture',label:'录制手势',icon:'⏺',cat:'interact',params:{points:[],speed:1.0}}
];
// 步骤默认值(「任务 → 动作配置」页配置,见 core/step_defaults.py);新建步骤时预填
let _stepDefaults = null;
// 去重有效期选了「按小时」才显示小时数输入框
function _toggleDedupHours(){
var sel=document.getElementById('f-dedup_reset');
var wrap=document.getElementById('f-dedup_hours_wrap');
if(sel&&wrap)wrap.style.display=(sel.value==='hours')?'inline-flex':'none';
}
var STEP_LIB_CATS=[['interact','交互操作'],['screen','屏幕与App'],['flow','流程控制']];
var _customActions=[]; // 后端加载的自定义动作
var _stepIdCounter=0;
@@ -50,30 +57,41 @@ function _stripIds(steps){
function _findLib(type){return STEP_LIB.find(function(s){return s.type===type;});}
// 选择器组件(类型下拉 + 值输入 + 抓取元素 + 测试按钮),click/long_click/swipe_until/wait_el/if_el 复用
// withOcr=true 时额外提供 OCR识别 选项(图片/画布里的文字,仅条件判断支持)
function _selRowHtml(p, extra, withOcr){
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App"两类非元素条件
var types=withOcr?['xpath','description','text','resourceId','descriptionContains','className','ocr','screen','foreground']:
['xpath','description','text','resourceId','descriptionContains','className'];
// withDedup=true 时额外提供「去重」条件类型(身份做过了吗,见 core/dedup.py)
// keys 可换字段名(默认 selector_type/selector_value)——「去重」的身份元素用
// ident_type/ident_value,好让同一个条件里既能有主选择器又能有身份元素
function _selRowHtml(p, extra, withOcr, withDedup, keys){
keys=keys||{};
var kType=keys.type||'selector_type', kVal=keys.value||'selector_value';
var typeList=['xpath','description','text','resourceId','descriptionContains','className'];
// withOcr(条件判断用)额外提供"屏幕状态 / 前台App / 去重"三类非元素条件
var types=withOcr?typeList.concat(['ocr','screen','foreground']):typeList.slice();
if(withDedup)types.push('dedup');
var labels={xpath:'xpath',description:'description',text:'text',resourceId:'resourceId',
descriptionContains:'descriptionContains',className:'className',ocr:'OCR识别',
screen:'屏幕状态',foreground:'前台App是'};
screen:'屏幕状态',foreground:'前台App是',dedup:'去重(这个身份做过了吗)'};
var opts=types.map(function(d){
return '<option value="'+d+'"'+(p.selector_type===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
return '<option value="'+d+'"'+(p[kType]===d?' selected':'')+'>'+labels[d]+'</option>';}).join('');
var toggle=withOcr?' onchange="_stepEditor._condTypeChanged(this)"':'';
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 isScreen=p.selector_type==='screen', isFg=p.selector_type==='foreground';
var isScreen=p[kType]==='screen', isFg=p[kType]==='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>';
valHtml='<select class="form-control" data-key="'+kVal+'" style="max-width:140px">'+
'<option value="off"'+(p[kVal]!=='on'?' selected':'')+'>熄屏时就命中</option>'+
'<option value="on"'+(p[kVal]==='on'?' selected':'')+'>亮屏时就命中</option></select>';
}else{
valHtml='<input type="text" class="form-control" data-key="selector_value" value="'+esc(p.selector_value||'')+
valHtml='<input type="text" class="form-control" data-key="'+kVal+'" value="'+esc(p[kVal]||'')+
'" 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>'+
var head='<div class="form-group"'+(keys.hideValue?' style="grid-column:span 3"':'')+'>'+
'<label>选择器类型</label><select class="form-control" data-key="'+kType+'"'+toggle+'>'+opts+'</select></div>';
// hideValue:「去重」条件不需要 selector_value(身份元素在 ident_value 里),
// 与其显示一个没人用的输入框,不如只留类型下拉
if(keys.hideValue)return '<div class="form-row">'+head+'</div>'+(extra||'');
return '<div class="form-row">'+head+
'<div class="form-group" style="grid-column:span 2"><label>'+valLabel+'</label><div class="sel-picker">'+
valHtml+'</div></div></div>'+(extra||'');
}
@@ -106,7 +124,7 @@ var _stepEditor={
_steps:[],
_selected:[],
_dragSuppressClick:false,
init:function(steps,maxDuration,draftKey,canvasId,libId){
init:function(steps,maxDuration,draftKey,canvasId,libId,taskExtra){
this._draftKey=draftKey||'new';
this._selected=[];
this._updatePackBtns();
@@ -147,6 +165,25 @@ var _stepEditor={
md.innerHTML='运行时长上限(秒,0=不限) <input type="number" id="f-max_duration" value="'+(maxDuration||0)+'" min="0" style="width:70px;padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px">';
head.appendChild(md);
}
// 去重有效期(任务级):**只在这一处配**——「条件判断→去重」和「记为已做」两处
// 各填一份的话,填不一致就会算出两个不同的 key、去重静默失效,所以强制共用。
var drHost=document.querySelector('#step-editor .se-head');
if(drHost&&!document.getElementById('f-dedup_reset')){
var ex=taskExtra||{};
var dr=document.createElement('span');
dr.style.cssText='font-size:11px;display:flex;align-items:center;gap:4px';
var selStyle='padding:2px 6px;border:1px solid var(--border);border-radius:4px;font-size:12px';
dr.innerHTML='去重有效期 <select id="f-dedup_reset" style="'+selStyle+'">'+
'<option value="day">每天一次</option>'+
'<option value="all">只做一次</option>'+
'<option value="hours">按小时</option></select>'+
'<span id="f-dedup_hours_wrap" style="display:none;align-items:center;gap:3px">每 '+
'<input type="number" id="f-dedup_hours" value="'+(ex.dedup_hours||6)+'" min="1" max="168" style="width:52px;'+selStyle+'"> 小时</span>';
drHost.appendChild(dr);
document.getElementById('f-dedup_reset').value=ex.dedup_reset||'day';
_toggleDedupHours();
document.getElementById('f-dedup_reset').addEventListener('change',_toggleDedupHours);
}
},
_ensureIds:function(steps){
var self=this;
@@ -423,9 +460,10 @@ var _stepEditor={
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>';
}else if(step.type==='if_el'){
// 屏幕状态 / 前台App 是"直接读设备当前状态",没有等待超时、也没有选择器可抓
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground');
// 文本比对:屏幕状态没有文本可比(亮/熄),只有元素/OCR/前台 App 才给这块
// 屏幕状态 / 前台App / 去重 是"直接读设备当前状态或账本",没有等待超时、也没有选择器可抓
var isDedup=(p.selector_type==='dedup');
var dyn=(p.selector_type==='screen'||p.selector_type==='foreground'||isDedup);
// 文本比对:屏幕状态没有文本可比(亮/熄)、去重也不比文本,只有元素/OCR/前台 App 才给这块
var cmpRow=dyn?'':'<div class="form-row"><div class="form-group"><label>文本比对</label>'+
'<select class="form-control" data-key="cmp_op">'+
[['','不比对(只看元素在不在)'],['等于','等于'],['不等于','不等于'],['包含','包含'],['不包含','不包含']]
@@ -436,12 +474,27 @@ var _stepEditor={
'placeholder="如 35377983067&#10;35377983068">'+esc(p.cmp_value||'')+'</textarea>'+
'<div class="help">一行一个(也认 <code>|</code> 分隔):<b>任意一个命中就算命中</b>——多个号的场景把号都列上。'+
'<b>逗号不当分隔符</b>,因为要比对的文本本身常带逗号</div></div></div>';
// 「去重」条件:身份元素用 ident_type/ident_value(主选择器那行只剩类型下拉)
var identRow=isDedup?_selRowHtml(p,
'<div class="help"><b>身份元素</b>:填"账号"那个元素(点「抓取元素」选,如抖音号那个 TextView)。'
+'<b>留空 = 用本设备当身份</b>(一号一机时这样就行)。'
+'<br>身份<b>读不到时不会去重</b>(当"没做过"照常执行)——宁可重复一次,也不会误判成"做过"而漏做。'
+'<br>有效期在上方任务级的<b>「去重有效期」</b>里设,检查和记账共用一套(不用在步骤里各填一遍)。</div>',
false, false, {type:'ident_type', value:'ident_value'}):'';
h+=_selRowHtml(p,
(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')+'">'+
'<input type="checkbox" data-key="ocr_click" '+(p.ocr_click?'checked':'')+'><label>OCR 命中后自动点击该文字</label></div>'+
cmpRow)+
'<div class="help">'+(dyn
'<div class="help">'+(isDedup
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。'
+'<b>去重</b>读的是平台的「去重账本」(所有设备共享一份,见「任务 → 去重记录」):'
+'<b>任何一台设备做过了,其余设备的这一步都会命中</b>——'
+'所以多个手机跑同一个任务、反复重跑,也不会把同一个号做两次。'
+'<br>建议这样摆:<b>命中分支</b>放「停止本设备」或「发通知」;'
+'<b>未命中分支</b>里放要做的动作(评论…),动作后面跟一个「记为已做」。'
+'<br>⚠ 记账要放在动作<b>成功之后</b>(用「记为已做」步骤),失败就不记账、下次还会重试。'
: (dyn
? '命中 → 执行下方"✅ 找到时"分支;未命中 → 执行"❌ 未找到时"分支。<b>屏幕状态</b>读系统 mWakefulness(亮/熄),'
+'<b>前台App是</b>判断当前前台是不是某个包名——都只看当前状态,不等待。'
: '超时内找到元素 → 执行下方"✅ 找到时"分支;未找到 → 执行"❌ 未找到时"分支。两分支可放任意步骤,也可再嵌套条件判断/循环;'
@@ -450,7 +503,16 @@ var _stepEditor={
+'比中了才算命中。例:元素文本是 <code>抖音号:35377983067</code>,'
+'想判断"这台登录的是不是这个号",选<b>包含</b>、值填 <code>35377983067</code> 即可(要整段一模一样才用"等于")。'
+'多个值一行一个,<b>任一命中即可</b>——"这几个号都算我的"。'
+'<br>⚠ 元素<b>没找到</b>时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。')+'</div>', true);
+'<br>⚠ 元素<b>没找到</b>时一律算未命中(走"未找到时"分支)——"没读到"不会被当成"和我设的不一样"。'))+'</div>',
true, true, isDedup?{hideValue:true}:null)+identRow;
}else if(step.type==='mark_done'){
h+=_selRowHtml(p,
'<div class="help">把<b>身份元素</b>的值记进平台「去重账本」(所有设备共享一份)。'
+'放在动作<b>成功之后</b>——动作失败就不会记账,下次重跑还会重试这台设备。'
+'<br><b>留空 = 用本设备当身份</b>(一号一机时这样就行)。'
+'身份读不到时<b>不记账</b>(记一个空身份会把别的设备挡住)。'
+'<br>有效期在上方任务级的<b>「去重有效期」</b>里设。'
+'<br>配套的「条件判断 → 去重」放在动作<b>之前</b>,两边填<b>同一个身份元素</b>。</div>');
}
// 共享:触发概率(所有步骤通用,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>';
@@ -822,6 +884,8 @@ var _stepEditor={
validate:function(steps){
// 校验步骤配置,返回警告列表
var warnings=[];
// 去重:收集「条件判断→去重」与「记为已做」各自用的身份元素,最后比对是否一致
var dedupIdents=[], hasDedupCheck=false, hasMarkDone=false;
function check(arr,prefix){
arr.forEach(function(s,i){
var name=prefix?(prefix+'.'+(i+1)):('步骤'+(i+1));
@@ -868,6 +932,15 @@ var _stepEditor={
if(s.params.cmp_op&&s.params.selector_type==='screen'){
warnings.push(name+' "'+label+'": 屏幕状态没有文本可比,文本比对的设置会被忽略。');
}
if(s.params.selector_type==='dedup'){
hasDedupCheck=true;
var iv=(s.params.ident_value||'').trim();
if(iv)dedupIdents.push({where:name,type:s.params.ident_type||'text',value:iv});
}
}else if(s.type==='mark_done'){
hasMarkDone=true;
var mv=(s.params.selector_value||'').trim();
if(mv)dedupIdents.push({where:name,type:s.params.selector_type||'text',value:mv});
}
_childArrs(s).forEach(function(a){
if(a.length)check(a,name+' 子步骤');
@@ -875,6 +948,25 @@ var _stepEditor={
});
}
check(steps,'');
// 去重的两个坑,保存时直接点出来(配错的话去重会「静默失效」,最难查)
if(hasDedupCheck&&!hasMarkDone){
warnings.push('任务里有「条件判断→去重」但没有「记为已做」:做完动作不会记账,'
+'下次重跑还会再做一遍(去重起不到作用)。请在动作后面加一个「记为已做」。');
}
if(hasMarkDone&&!hasDedupCheck){
warnings.push('任务里有「记为已做」但没有「条件判断→去重」:只会记账、不会拦重复。'
+'请在动作前面加一个条件判断、类型选「去重」。');
}
if(dedupIdents.length>1){
var first=dedupIdents[0];
dedupIdents.slice(1).forEach(function(d){
if(d.value!==first.value||d.type!==first.type){
warnings.push('去重的身份元素不一致:'+first.where+'用的是「'+first.value
+'」,而'+d.where+'用的是「'+d.value+'」——两边不一致会算出不同的 key,'
+'去重会静默失效。请改成同一个元素。');
}
});
}
return warnings;
},
_syncParams:function(container,steps){
@@ -1367,6 +1459,13 @@ async function saveTask(jobId){
}
const mdEl=document.getElementById('f-max_duration');
params={max_duration:mdEl?parseInt(mdEl.value)||0:0,steps:steps};
// 去重有效期(任务级;「条件判断→去重」与「记为已做」共用这一套)
const drEl=document.getElementById('f-dedup_reset');
params.dedup_reset=drEl?drEl.value:'day';
if(drEl&&drEl.value==='hours'){
const dhEl=document.getElementById('f-dedup_hours');
params.dedup_hours=Math.max(1,parseInt(dhEl&&dhEl.value)||6);
}
// 公共巡检(任务级,独立于步骤画布;实现在 tasks.js + core/patrol.py)
if(typeof patrolCollect==='function'){
const ws=patrolCollect();